<?php
/**
 * /backreload/v1/backups  endpoints.
 *
 * @package Backreload\Api
 */

namespace Backreload\Api;

use Backreload\Models\BackupRecord;
use WP_REST_Request;

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

class BackupsController extends BaseController {

	public function register( string $namespace ): void {
		register_rest_route( $namespace, '/backups', [
			[
				'methods'             => 'GET',
				'callback'            => [ $this, 'index' ],
				'permission_callback' => [ $this, 'check_perm' ],
			],
			[
				'methods'             => 'POST',
				'callback'            => [ $this, 'create' ],
				'permission_callback' => [ $this, 'check_perm' ],
			],
		] );

		// IMPORTANT: the `{id}` regex MUST start with `bk_` so it doesn't
		// shadow the action endpoints below (`/backups/import`,
		// `/backups/import-source`, `/backups/import-folder`, `/backups/sync`).
		// Previously the regex was `[a-zA-Z0-9_-]+` — which matched literal
		// strings like "import-source" as if they were backup IDs. The result:
		// `GET /backups/import-source` → resolved to the show-backup-by-id
		// route → BackupRecord::find('import-source') → null → 404
		// "Backup not found". The user has been seeing exactly that error in
		// the Import → From destination → Scan flow, because the route never
		// reached `import_source_scan`. Constraining to the actual ID shape
		// (`bk_<alphanum>`) makes the prefixes mutually exclusive.
		register_rest_route( $namespace, '/backups/(?P<id>bk_[a-zA-Z0-9_-]+)', [
			[
				'methods'             => 'GET',
				'callback'            => [ $this, 'show' ],
				'permission_callback' => [ $this, 'check_perm' ],
			],
			[
				'methods'             => 'DELETE',
				'callback'            => [ $this, 'destroy' ],
				'permission_callback' => [ $this, 'check_perm' ],
			],
		] );

		register_rest_route( $namespace, '/backups/(?P<id>bk_[a-zA-Z0-9_-]+)/restore', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'restore' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );

		register_rest_route( $namespace, '/backups/(?P<id>bk_[a-zA-Z0-9_-]+)/cancel', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'cancel' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );

		register_rest_route( $namespace, '/backups/(?P<id>bk_[a-zA-Z0-9_-]+)/resume', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'resume' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );

		register_rest_route( $namespace, '/backups/(?P<id>bk_[a-zA-Z0-9_-]+)/download', [
			'methods'             => 'GET',
			'callback'            => [ $this, 'download' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );

		register_rest_route( $namespace, '/backups/import', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'import' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );

		// Firewall-proof import: the user drops backup parts into a folder on
		// the server (via FTP / file manager), GET lists them, POST assembles
		// them into one restorable backup. No HTTP file upload involved.
		register_rest_route( $namespace, '/backups/import-folder', [
			[
				'methods'             => 'GET',
				'callback'            => [ $this, 'import_folder_scan' ],
				'permission_callback' => [ $this, 'check_perm' ],
			],
			[
				'methods'             => 'POST',
				'callback'            => [ $this, 'import_folder_commit' ],
				'permission_callback' => [ $this, 'check_perm' ],
			],
		] );

		// Import from a storage destination: GET browses a configured
		// destination for backups created on any site, POST recreates the
		// chosen backup as a local record (metadata only — restore pulls the
		// bytes on demand). This is the cross-server migration path.
		register_rest_route( $namespace, '/backups/import-source', [
			[
				'methods'             => 'GET',
				'callback'            => [ $this, 'import_source_scan' ],
				'permission_callback' => [ $this, 'check_perm' ],
			],
			[
				'methods'             => 'POST',
				'callback'            => [ $this, 'import_source_commit' ],
				'permission_callback' => [ $this, 'check_perm' ],
			],
		] );

		// One-click sync: scan every connected, non-paused, browsable
		// destination and auto-import any bk_* folder that isn't already a
		// local record. Used by the Backups page "Sync" button and a session-
		// once auto-fire on page load, so the user never has to manually
		// reconcile orphan folders on FTP/S3 with the DB.
		register_rest_route( $namespace, '/backups/sync', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'sync_from_destinations' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );

		// Companion to sync: the user deleted backup folders directly on the
		// destination (e.g. `rm -rf bk_*` over SSH), and now the plugin DB
		// still lists those backups as if they exist. This endpoint sweeps
		// out the rows that the most recent sync flagged as
		// "missing_on_destination" — DB-only, no destination touch.
		register_rest_route( $namespace, '/backups/cleanup-broken', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'cleanup_broken' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );
	}

	/**
	 * Import an existing backup by uploading its part files in small chunks.
	 *
	 * Why chunked: a multi-zip backup is a *set* of part files (db.sql.gz,
	 * plugins-1.zip, …) and any one of them can be gigabytes. A single whole-file
	 * POST is killed by PHP's `post_max_size` / `upload_max_filesize` and by the
	 * web server's request-body cap (nginx `client_max_body_size`). So the
	 * browser slices every part into small chunks and sends them one at a time;
	 * each request stays tiny and always fits within default host limits.
	 *
	 * Transport — each chunk is gzip-compressed, then base64-encoded, then sent
	 * as a file upload (`chunk`) whose CONTENT is that base64 text. This threads
	 * every firewall needle:
	 *   1. It is a *file* upload, so mod_security measures it against the
	 *      generous `SecRequestBodyLimit` (~13 MB) — not the ~128 KB
	 *      `SecRequestBodyNoFilesLimit` that a plain text field would hit.
	 *   2. Firewalls base64-DECODE content and re-scan it, so base64 alone
	 *      doesn't hide the PHP source inside a theme/plugin zip — that decoded
	 *      code is what triggers "406 Not Acceptable". Gzipping first makes the
	 *      decoded bytes opaque, high-entropy data (just like db.sql.gz, which
	 *      sails through), so no content rule matches.
	 *
	 * Request fields:
	 *   chunk        — file upload; its content is base64( gzip( raw chunk ) )
	 *   file_name    — original part filename, e.g. "uploads-1.zip" / "db.sql.gz"
	 *   upload_id    — opaque per-file token; names the temp staging file
	 *   chunk_index  — 0-based index of this chunk
	 *   chunk_total  — total chunks for this file
	 *   backup_id    — empty for the very first chunk; the id we return thereafter
	 *
	 * Non-final chunk → {"received":N,"of":M}. Final chunk of a file → the full
	 * BackupRecord (so the browser can read `id` and reuse it for the next part).
	 * Each completed file is appended to the record's files[] with its real
	 * category, so Restore treats the result as a native multi-zip backup.
	 */
	public function import( WP_REST_Request $req ) {
		$files = $req->get_file_params();
		$chunk = $files['chunk'] ?? null;
		if ( ! $chunk || empty( $chunk['tmp_name'] ) ) {
			return $this->error( 'no_chunk', 'No chunk data received, the request may have been blocked or exceeded the server size limit.', 400 );
		}
		if ( ! empty( $chunk['error'] ) ) {
			return $this->error( 'upload_error', 'Upload failed: PHP error code ' . $chunk['error'], 400 );
		}
		$decoded     = base64_decode( (string) file_get_contents( $chunk['tmp_name'] ), true );
		$chunk_bytes = ( false !== $decoded ) ? @gzdecode( $decoded ) : false;
		if ( false === $chunk_bytes ) {
			return $this->error( 'bad_chunk', 'Chunk data was corrupted in transit. Please retry the import.', 400 );
		}
		// Decompression-bomb guard: the browser sends ~0.7 MB raw chunks, so a
		// decoded chunk far larger than that is malformed or hostile (gzip can
		// inflate a tiny payload to gigabytes). 16 MB is a generous ceiling.
		if ( strlen( $chunk_bytes ) > 16 * 1024 * 1024 ) {
			return $this->error( 'chunk_too_big', 'Chunk exceeds the maximum decoded size.', 400 );
		}

		$upload_id   = preg_replace( '/[^A-Za-z0-9]/', '', (string) $req->get_param( 'upload_id' ) );
		// Sanitize the part filename WITHOUT WordPress's sanitize_file_name() —
		// its double-extension protection rewrites "db.sql.gz" to "db.sql_.gz",
		// which then fails part classification. We only need a flat, path-safe
		// name: drop directory components, keep dots, strip anything unusual.
		$file_name   = basename( str_replace( '\\', '/', (string) $req->get_param( 'file_name' ) ) );
		$file_name   = (string) preg_replace( '/[^A-Za-z0-9._-]/', '', $file_name );
		$chunk_index = (int) $req->get_param( 'chunk_index' );
		$chunk_total = (int) $req->get_param( 'chunk_total' );
		$backup_id   = (string) ( $req->get_param( 'backup_id' ) ?? '' );

		if ( '' === $upload_id || '' === $file_name || $chunk_total < 1 || $chunk_index < 0 || $chunk_index >= $chunk_total ) {
			return $this->error( 'bad_request', 'Malformed chunk request.', 400 );
		}

		// Classify the part by filename — refuse anything that isn't a backup part.
		$cls = $this->classify_import_part( $file_name );
		if ( null === $cls ) {
			return $this->error(
				'bad_type',
				"Unrecognized or unsupported file \"{$file_name}\". Import accepts data parts only (db.sql.gz, uploads-N.zip, others-N.zip) or a single .zip backup. Plugin, theme, and mu-plugin parts are not imported — reinstall those through the WordPress installer.",
				415
			);
		}
		[ $category, $is_encrypted ] = $cls;

		// Find a connected Local destination to store the import in.
		$local = null;
		foreach ( \Backreload\Models\DestinationRecord::all() as $d ) {
			if ( 'local' === $d->type && 'connected' === $d->status ) {
				$local = $d;
				break;
			}
		}
		if ( ! $local ) {
			return $this->error( 'no_local', 'Connect a Local Storage destination first: imported archives are stored locally.', 400 );
		}
		$dir = $this->local_storage_dir( $local );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Read-only writability probe of a target path.
		if ( ! is_writable( $dir ) ) {
			return $this->error( 'not_writable', "Storage path is not writable: {$dir}", 500 );
		}

		// Drop staging files left behind by uploads that were abandoned long ago.
		$this->prune_import_staging( $dir );

		// ── Stage the chunk: append it to a per-file temp file. ────────────
		$tmp_dir = trailingslashit( $dir ) . '.backreload-import-tmp';
		if ( ! is_dir( $tmp_dir ) ) {
			wp_mkdir_p( $tmp_dir );
		}
		$stage = $tmp_dir . '/' . $upload_id . '.part';

		// The first chunk truncates any leftover so a retry starts clean.
		if ( 0 === $chunk_index ) {
			@unlink( $stage ); // 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.
		}

		// Disk-exhaustion guard: $chunk_total is client-supplied, so an attacker
		// could stream chunks forever. Cap the assembled part at a sane ceiling
		// (individual backup parts never approach this — the chunk threshold
		// keeps them ~1 GB).
		//
		// The cap check + append must happen under the SAME flock window —
		// previously the size check was outside the LOCK_EX held by
		// file_put_contents(), so two concurrent chunks could both see
		// "under cap" then both append, exceeding the limit. Holding the
		// lock across read-current-size + append closes that race.
		$cap_bytes  = 12 * 1024 * 1024 * 1024;
		$chunk_len  = strlen( $chunk_bytes );
		// 'cb' = create-if-missing + read/write + don't truncate. We seek to
		// the end manually after taking the lock so we get an honest size.
		// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- WP_Filesystem buffers whole files and cannot stream; the import/migration transport reads & writes multi-GB archives via direct handles under the plugin's own paths.
		$fp = @fopen( $stage, 'cb' );
		if ( false === $fp ) {
			return $this->error( 'stage_failed', 'Could not open the upload staging file.', 500 );
		}
		if ( ! @flock( $fp, LOCK_EX ) ) {
			fclose( $fp );
			return $this->error( 'stage_failed', 'Could not lock the upload staging file.', 500 );
		}
		@fseek( $fp, 0, SEEK_END );
		$cur_size = (int) @ftell( $fp );
		if ( $cur_size + $chunk_len > $cap_bytes ) {
			@flock( $fp, LOCK_UN );
			fclose( $fp );
			@unlink( $stage ); // 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.
			return $this->error( 'too_large', 'Imported part exceeds the maximum size.', 400 );
		}
		$written = @fwrite( $fp, $chunk_bytes );
		@fflush( $fp );
		@flock( $fp, LOCK_UN );
		fclose( $fp );
		// phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
		if ( false === $written || $written < $chunk_len ) {
			return $this->error( 'stage_failed', 'Could not write the upload staging file.', 500 );
		}

		// Not the last chunk → acknowledge and wait for the rest.
		if ( $chunk_index < $chunk_total - 1 ) {
			return $this->ok( [ 'received' => $chunk_index + 1, 'of' => $chunk_total ] );
		}

		// ── Last chunk: the staged file is the complete part. ──────────────
		// Magic-byte sanity check (skipped for encrypted parts — ciphertext
		// has no signature).
		if ( ! $is_encrypted ) {
			$magic = (string) file_get_contents( $stage, false, null, 0, 4 );
			$looks_zip  = ( "PK\x03\x04" === substr( $magic, 0, 4 ) || "PK\x05\x06" === substr( $magic, 0, 4 ) );
			$looks_gzip = ( "\x1f\x8b" === substr( $magic, 0, 2 ) );
			$lname      = strtolower( $file_name );
			if ( '.zip' === substr( $lname, -4 ) && ! $looks_zip ) {
				@unlink( $stage ); // 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.
				return $this->error( 'not_zip', "\"{$file_name}\" does not look like a valid ZIP archive.", 415 );
			}
			if ( '.gz' === substr( $lname, -3 ) && ! $looks_gzip ) {
				@unlink( $stage ); // 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.
				return $this->error( 'not_gzip', "\"{$file_name}\" does not look like a valid gzip file.", 415 );
			}
		}

		// Resolve (or create) the backup record this part belongs to.
		if ( '' !== $backup_id ) {
			$rec = BackupRecord::find( $backup_id );
			if ( ! $rec ) {
				@unlink( $stage ); // 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.
				return $this->error( 'not_found', 'Import target backup not found.', 404 );
			}
			$created = false;
		} else {
			$rec = BackupRecord::create( [
				'type'         => 'full',
				'status'       => 'pending',
				'components'   => [],
				'destinations' => [ $local->ext_id ],
				'encrypted'    => false,
			] );
			$created = true;
		}

		// Move the completed part into the backup's per-backup subfolder,
		// matching the layout native multi-zip backups use.
		$target_name   = $rec->ext_id . '/' . $file_name; // relative path stored in files[]
		$target_path   = trailingslashit( $dir ) . $target_name;
		$target_parent = dirname( $target_path );
		if ( ! is_dir( $target_parent ) ) {
			wp_mkdir_p( $target_parent );
		}
		@unlink( $target_path ); // 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 ( ! @rename( $stage, $target_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic rename of the plugin's own temp file into place; WP_Filesystem::move is non-atomic.
			if ( $created ) {
				$rec->delete();
			}
			@unlink( $stage ); // 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.
			return $this->error( 'move_failed', 'Failed to save the uploaded part.', 500 );
		}

		$size = (int) filesize( $target_path );
		$sha  = hash_file( 'sha256', $target_path );

		// Append to files[] — replacing any prior entry with the same name so a
		// re-uploaded part doesn't appear twice.
		$entries = array_values( array_filter(
			(array) $rec->files,
			static fn( $f ) => ( $f['name'] ?? '' ) !== $target_name
		) );
		$entries[] = [
			'name'        => $target_name,
			'category'    => $category,
			'size'        => $size,
			'checksum'    => $sha,
			'encrypted'   => $is_encrypted,
			'uploaded_to' => [ $local->ext_id ],
		];
		$rec->files = $entries;

		// Re-derive type / components / size / encrypted from the parts so far.
		$this->recompute_import_meta( $rec );

		// Best-effort: sniff the original site URL from the DB part so the
		// cross-domain URL rewrite still has something to work with on restore.
		if ( 'db' === $category && ! $is_encrypted && empty( $rec->source_url ) ) {
			$sniffed = $this->sniff_source_url_from_db( $target_path );
			if ( $sniffed ) {
				$rec->source_url = $sniffed;
			}
		}

		$rec->status       = 'success';
		$rec->completed_at = current_time( 'mysql', true );
		$rec->save();

		$this->plugin->get( 'logger' )->info(
			"Imported backup part: {$file_name} ({$size} bytes, category {$category})",
			[],
			$rec->ext_id
		);

		return $this->ok( $rec->to_array(), $created ? 201 : 200 );
	}

	/**
	 * GET /backups/import-folder
	 *
	 * Firewall-proof import, step 1. The user copies backup part files into a
	 * folder on the server using FTP / SFTP / cPanel File Manager — channels a
	 * web-application firewall never inspects. This lists what's currently in
	 * that drop folder so the UI can confirm before committing.
	 */
	public function import_folder_scan( WP_REST_Request $req ) {
		$local = $this->find_connected_local();
		if ( ! $local ) {
			return $this->error( 'no_local', 'Connect a Local Storage destination first.', 400 );
		}
		$dir  = $this->local_storage_dir( $local );
		$drop = trailingslashit( $dir ) . 'backreload-import';
		if ( ! is_dir( $drop ) ) {
			wp_mkdir_p( $drop );
		}

		$parts   = [];
		$unknown = [];
		foreach ( (array) glob( trailingslashit( $drop ) . '*' ) as $f ) {
			if ( ! is_file( $f ) ) {
				continue;
			}
			$name = basename( $f );
			if ( \Backreload\Core\Manifest::FILENAME === $name ) {
				continue; // metadata file, not a part — don't flag as unknown
			}
			$cls = $this->classify_import_part( $name );
			if ( $cls ) {
				$parts[] = [ 'name' => $name, 'category' => $cls[0], 'size' => (int) filesize( $f ) ];
			} else {
				$unknown[] = $name;
			}
		}

		return $this->ok( [
			'folder'  => $drop,
			'parts'   => $parts,
			'unknown' => $unknown,
		] );
	}

	/**
	 * POST /backups/import-folder
	 *
	 * Firewall-proof import, step 2. Assembles every recognized part currently
	 * in the drop folder into ONE restorable backup, moving the files into the
	 * standard per-backup subfolder layout.
	 */
	public function import_folder_commit( WP_REST_Request $req ) {
		$local = $this->find_connected_local();
		if ( ! $local ) {
			return $this->error( 'no_local', 'Connect a Local Storage destination first.', 400 );
		}
		$dir = $this->local_storage_dir( $local );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Read-only writability probe of a target path.
		if ( ! is_writable( $dir ) ) {
			return $this->error( 'not_writable', "Storage path is not writable: {$dir}", 500 );
		}
		$drop = trailingslashit( $dir ) . 'backreload-import';

		// A manifest.json dropped alongside the parts makes the import exact —
		// authoritative categories, encryption flags, source URL. Optional:
		// without it we classify each part by its filename.
		$manifest   = \Backreload\Core\Manifest::read( trailingslashit( $drop ) . \Backreload\Core\Manifest::FILENAME );
		$mf_by_base = [];
		if ( $manifest && ! empty( $manifest['files'] ) ) {
			foreach ( (array) $manifest['files'] as $mf ) {
				$mf_by_base[ basename( (string) ( $mf['name'] ?? '' ) ) ] = $mf;
			}
		}

		// Collect recognized parts from the drop folder.
		$found = [];
		foreach ( (array) glob( trailingslashit( $drop ) . '*' ) as $f ) {
			if ( ! is_file( $f ) ) {
				continue;
			}
			$name = basename( $f );
			if ( \Backreload\Core\Manifest::FILENAME === $name ) {
				continue; // metadata, not a restorable part
			}
			if ( isset( $mf_by_base[ $name ] ) ) {
				// Manifest knows exactly what this part is.
				$mf      = $mf_by_base[ $name ];
				$found[] = [
					'path'     => $f,
					'name'     => $name,
					'category' => (string) ( $mf['category'] ?? 'others' ),
					'enc'      => ! empty( $mf['encrypted'] ),
				];
				continue;
			}
			$cls = $this->classify_import_part( $name );
			if ( $cls ) {
				$found[] = [ 'path' => $f, 'name' => $name, 'category' => $cls[0], 'enc' => $cls[1] ];
			}
		}
		if ( ! $found ) {
			return $this->error(
				'no_parts',
				'No backup parts found in the import folder. Copy the data parts (db.sql.gz, uploads-N.zip, others-N.zip) into it, then try again.',
				400
			);
		}

		$rec = BackupRecord::create( [
			'type'         => 'full',
			'kind'         => $manifest ? (string) ( $manifest['kind'] ?? 'manual' ) : 'manual',
			'status'       => 'pending',
			'components'   => [],
			'destinations' => [ $local->ext_id ],
			'encrypted'    => false,
			'source_url'   => $manifest ? ( (string) ( $manifest['source_url'] ?? '' ) ?: null ) : null,
			'source_path'  => $manifest ? ( (string) ( $manifest['source_path'] ?? '' ) ?: null ) : null,
		] );

		$entries = [];
		foreach ( $found as $p ) {
			$target_name   = $rec->ext_id . '/' . $p['name'];
			$target_path   = trailingslashit( $dir ) . $target_name;
			$target_parent = dirname( $target_path );
			if ( ! is_dir( $target_parent ) ) {
				wp_mkdir_p( $target_parent );
			}
			@unlink( $target_path ); // 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.
			// rename() first (fast, same filesystem); fall back to copy+unlink.
			if ( ! @rename( $p['path'], $target_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic rename of the plugin's own temp file into place; WP_Filesystem::move is non-atomic.
				if ( ! @copy( $p['path'], $target_path ) ) {
					$rec->delete();
					return $this->error( 'move_failed', "Failed to move part \"{$p['name']}\" into place.", 500 );
				}
				@unlink( $p['path'] ); // 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.
			}
			$entries[] = [
				'name'        => $target_name,
				'category'    => $p['category'],
				'size'        => (int) filesize( $target_path ),
				'checksum'    => hash_file( 'sha256', $target_path ),
				'encrypted'   => $p['enc'],
				'uploaded_to' => [ $local->ext_id ],
			];
		}
		$rec->files = $entries;
		$this->recompute_import_meta( $rec );

		// Source URL: trust the manifest if it has one, else sniff the DB part.
		if ( empty( $rec->source_url ) ) {
			foreach ( $entries as $e ) {
				if ( 'db' === $e['category'] && empty( $e['encrypted'] ) ) {
					$sniffed = $this->sniff_source_url_from_db( trailingslashit( $dir ) . $e['name'] );
					if ( $sniffed ) {
						$rec->source_url = $sniffed;
					}
					break;
				}
			}
		}

		// The consumed manifest has done its job — drop it from the folder.
		@unlink( trailingslashit( $drop ) . \Backreload\Core\Manifest::FILENAME ); // 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.

		$rec->status       = 'success';
		$rec->completed_at = $manifest
			? ( (string) ( $manifest['completed_at'] ?? '' ) ?: current_time( 'mysql', true ) )
			: current_time( 'mysql', true );
		$rec->save();

		$this->plugin->get( 'logger' )->info(
			'Imported backup from server folder: ' . count( $entries ) . ' part(s)',
			[],
			$rec->ext_id
		);

		return $this->ok( $rec->to_array(), 201 );
	}

	/**
	 * GET /backups/import-source?destination_id=…
	 *
	 * Browse a configured storage destination for backups — including backups
	 * created on a different site. The cross-server migration entry point.
	 */
	public function import_source_scan( WP_REST_Request $req ) {
		// Listing a remote destination is a sequence of network round-trips —
		// a destination with many backups can take a while. Lift the time cap.
		@set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running backup/restore needs an extended time/memory limit, scoped to the engine's own execution.

		$dest_id = (string) $req->get_param( 'destination_id' );
		$dest    = \Backreload\Models\DestinationRecord::find( $dest_id );
		if ( ! $dest ) {
			return $this->error( 'not_found', 'Destination not found.', 404 );
		}

		$handler = $this->plugin->get( 'destinations' )->handler( $dest->type );
		if ( method_exists( $handler, 'is_browsable' ) && ! $handler->is_browsable() ) {
			return $this->error(
				'not_browsable',
				$dest->name . ' (' . $dest->type . ') cannot be browsed for backups. Use a Local, FTP, SFTP or S3 destination.',
				400
			);
		}

		try {
			$found = $handler->list_backups( $dest );
		} catch ( \Throwable $e ) {
			return $this->error( 'list_failed', 'Could not list backups in this destination: ' . $e->getMessage(), 502 );
		}

		$items = [];
		foreach ( $found as $b ) {
			$ext_id   = (string) ( $b['ext_id'] ?? '' );
			$manifest = is_array( $b['manifest'] ?? null ) ? $b['manifest'] : null;
			$parts    = (array) ( $b['parts'] ?? [] );
			$size     = $manifest
				? (int) ( $manifest['size'] ?? 0 )
				: array_sum( array_map( static fn( $p ) => (int) ( $p['size'] ?? 0 ), $parts ) );
			$items[]  = [
				'ext_id'           => $ext_id,
				'already_imported' => null !== BackupRecord::find( $ext_id ),
				'has_manifest'     => ! empty( $b['has_manifest'] ),
				'type'             => $manifest ? (string) ( $manifest['type'] ?? 'full' ) : 'full',
				'created_at'       => $manifest ? (string) ( $manifest['created_at'] ?? '' ) : '',
				'source_url'       => $manifest ? (string) ( $manifest['source_url'] ?? '' ) : '',
				'parts'            => count( $parts ),
				'size'             => $size,
			];
		}

		return $this->ok( [ 'destination' => $dest->name, 'backups' => $items ] );
	}

	/**
	 * POST /backups/import-source  { destination_id, ext_id }
	 *
	 * Recreate a backup found in a destination as a local record. No bytes are
	 * downloaded — the record points its files[] at the destination, and the
	 * restore engine pulls each part on demand (RestoreEngine::resolve_source).
	 */
	public function import_source_commit( WP_REST_Request $req ) {
		// re-reads the destination listing — can be slow.
		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running backup/restore needs an extended time/memory limit, scoped to the engine's own execution.
		@set_time_limit( 0 );

		$dest_id = (string) $req->get_param( 'destination_id' );
		$ext_id  = (string) $req->get_param( 'ext_id' );
		$dest    = \Backreload\Models\DestinationRecord::find( $dest_id );
		if ( ! $dest ) {
			return $this->error( 'not_found', 'Destination not found.', 404 );
		}
		// ext_id becomes the record id AND a path segment in files[].name —
		// constrain it to the backup-id shape before it touches anything.
		if ( ! preg_match( '/^bk_[A-Za-z0-9_-]{1,64}$/', $ext_id ) ) {
			return $this->error( 'bad_request', 'Malformed backup id.', 400 );
		}
		if ( BackupRecord::find( $ext_id ) ) {
			return $this->error( 'already_exists', 'This backup is already in your list.', 409 );
		}

		$handler = $this->plugin->get( 'destinations' )->handler( $dest->type );
		try {
			$found = $handler->list_backups( $dest );
		} catch ( \Throwable $e ) {
			$this->plugin->get( 'logger' )->error(
				'Destination listing failed: ' . $e->getMessage(),
				[ 'destination_id' => $dest_id, 'destination_type' => $dest->type ]
			);
			// Don't echo upstream error verbatim — S3/cloud errors can reveal
			// bucket existence and other reconnaissance info to anyone who
			// finds an admin XSS. The full message is in the plugin log.
			return $this->error( 'list_failed', 'Could not read the destination. See the plugin logs for details.', 502 );
		}

		$entry = null;
		foreach ( $found as $b ) {
			if ( (string) ( $b['ext_id'] ?? '' ) === $ext_id ) {
				$entry = $b;
				break;
			}
		}
		if ( ! $entry ) {
			return $this->error( 'not_found', 'That backup was not found in the destination.', 404 );
		}

		try {
			$rec = $this->import_destination_entry( $entry, $dest );
		} catch ( \RuntimeException $e ) {
			$code = $e->getCode() ?: 400;
			return $this->error( 'import_failed', $e->getMessage(), (int) $code );
		}

		return $this->ok( $rec->to_array(), 201 );
	}

	/**
	 * POST /backups/sync
	 *
	 * Walk every connected, non-paused, browsable destination and auto-import
	 * any bk_* folder that doesn't already have a local record. Pure metadata
	 * — no bytes are downloaded; restore lazily pulls files via resolve_source.
	 *
	 * Idempotent and safe to call repeatedly: a backup that's already in the
	 * DB is skipped, so re-running just discovers any *new* folders that
	 * appeared since the last sync (e.g. backups created by a sibling site
	 * pointing at the same destination).
	 *
	 * Returns:
	 *   { imported: [{ ext_id, destination, source_url }],
	 *     skipped:  [{ ext_id, reason }],
	 *     errors:   [{ destination, message }] }
	 *
	 * Errors are PER-DESTINATION — one bad destination doesn't abort the rest.
	 * This is important because users with a long destination list shouldn't
	 * have the whole sync break because (say) one S3 bucket lost its IAM
	 * permission.
	 */
	public function sync_from_destinations( WP_REST_Request $req ) {
		@set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running backup/restore needs an extended time/memory limit, scoped to the engine's own execution.
		// Only honour an explicit destination_id filter so the UI can drive a
		// per-destination sync from the destinations page if we ever add one.
		$only_dest_id = (string) $req->get_param( 'destination_id' );

		$imported = [];
		$skipped  = [];
		$errors   = [];

		// Map every successfully-scanned destination to the set of ext_ids it
		// currently holds. We use this both to drive the "new folder → import"
		// loop AND, after the loop, to detect *broken* records — local DB rows
		// whose ext_id no longer exists on any of the destinations they claim
		// to live on. The broken set is surfaced via a transient that the list
		// endpoint reads, so the UI can flag those rows and offer a one-click
		// cleanup.
		//
		// Map shape: [ destination_ext_id => [ ext_id_1 => true, ext_id_2 => true ] ]
		// We use a hash so membership lookup is O(1).
		$scanned_by_dest = [];

		foreach ( \Backreload\Models\DestinationRecord::all() as $dest ) {
			if ( $only_dest_id && $dest->ext_id !== $only_dest_id ) continue;
			// Skip destinations we know we can't read from. A "disconnected"
			// destination's credentials are likely stale (or, for FTP, broken
			// after a salt rotation) — attempting list_backups on it would
			// just hang or throw. Paused destinations are user-disabled.
			if ( 'connected' !== $dest->status ) continue;
			if ( ! empty( $dest->paused ) )       continue;

			$handler = $this->plugin->get( 'destinations' )->handler( $dest->type );
			if ( method_exists( $handler, 'is_browsable' ) && ! $handler->is_browsable() ) {
				continue; // Destination handler doesn't override list_backups().
			}

			try {
				$found = $handler->list_backups( $dest );
			} catch ( \Throwable $e ) {
				$this->plugin->get( 'logger' )->warn(
					'Sync skipped destination: listing failed: ' . $e->getMessage(),
					[ 'destination_id' => $dest->ext_id, 'destination_type' => $dest->type ]
				);
				$errors[] = [
					'destination' => $dest->name,
					'message'     => 'Could not list backups on this destination. Check its connection and credentials.',
				];
				continue;
			}

			// Index the destination's contents so the broken-record pass below
			// can do O(1) presence checks. Pre-allocate the destination key so
			// a destination that genuinely holds zero backups (e.g. brand-new
			// bucket) is still considered "scanned" — it just has an empty set.
			$scanned_by_dest[ $dest->ext_id ] = [];

			foreach ( $found as $entry ) {
				$ext_id = (string) ( $entry['ext_id'] ?? '' );
				if ( '' === $ext_id ) continue;
				if ( ! preg_match( '/^bk_[A-Za-z0-9_-]{1,64}$/', $ext_id ) ) {
					$skipped[] = [ 'ext_id' => $ext_id, 'reason' => 'malformed id, skipped for safety' ];
					continue;
				}
				$scanned_by_dest[ $dest->ext_id ][ $ext_id ] = true;

				if ( BackupRecord::find( $ext_id ) ) {
					$skipped[] = [ 'ext_id' => $ext_id, 'reason' => 'already in your list' ];
					continue;
				}

				// Suppress orphan partials: a folder that has NO manifest AND
				// only contains db.sql.gz is almost always the residue of an
				// interrupted backup (engine paused right after the DB dump
				// but before the file-archive uploads finished). Importing it
				// as a legit "database" backup confuses the user — they think
				// they made a separate database-only backup when in fact it's
				// a leftover from a crashed full backup. Skip these so they
				// don't pollute the Backups list. Manual import via the
				// "From destination" UI still imports them if the user
				// explicitly wants to recover one.
				$has_manifest  = ! empty( $entry['has_manifest'] );
				$parts         = (array) ( $entry['parts'] ?? [] );
				$part_names    = array_map(
					static fn( $p ) => basename( (string) ( $p['name'] ?? '' ) ),
					$parts
				);
				$only_db_file  = 1 === count( $part_names )
					&& 1 === count( array_filter( $part_names, static fn( $n ) => 'db.sql.gz' === $n ) );
				if ( ! $has_manifest && $only_db_file ) {
					$skipped[] = [
						'ext_id' => $ext_id,
						'reason' => 'partial folder (db.sql.gz only, no manifest); likely a leftover from an interrupted backup. Delete the folder on the destination if you don\'t need it.',
					];
					continue;
				}

				try {
					$rec = $this->import_destination_entry( $entry, $dest );
					$imported[] = [
						'ext_id'      => $rec->ext_id,
						'destination' => $dest->name,
						'source_url'  => (string) ( $rec->source_url ?? '' ),
						'size'        => (int) $rec->size,
					];
				} catch ( \RuntimeException $e ) {
					$skipped[] = [ 'ext_id' => $ext_id, 'reason' => $e->getMessage() ];
				}
			}
		}

		// Broken-record detection. Now that we know what every reachable
		// destination actually contains, walk the local DB and flag any record
		// whose ext_id is *missing* from every destination it claims to live
		// on — provided we successfully scanned at least one of those
		// destinations. We deliberately do NOT auto-delete here: the user
		// might have unplugged a destination temporarily, and we don't want
		// to silently nuke records over a transient outage. Instead we
		// surface the flag via a transient and let the UI offer a one-click
		// cleanup.
		//
		// Rules (intentionally conservative — false positives delete user data):
		//   1. The record must be in a terminal state (success/failed/skipped).
		//      Running/pending records are mid-flight, not orphaned.
		//   2. At least ONE of the record's destinations must have been
		//      successfully scanned this sync. If every destination was
		//      offline or skipped, we abstain — we can't prove the record is
		//      gone, only that we couldn't see it.
		//   3. The record's ext_id must be absent from every scanned
		//      destination it lists. A record lingers as broken only when
		//      EVERY destination that's currently reachable says "not here."
		//   4. If the record has no destinations at all (an import-folder
		//      that targeted only a local upload that's since been deleted),
		//      we leave it alone — that's a different problem the user
		//      handles by deleting the row manually.
		//   5. Records whose ONLY destination is paused/disconnected are
		//      skipped — same conservatism as #2.
		$broken_ids = [];
		if ( ! $only_dest_id ) {
			// Only run the broken-record pass on a full sync. A per-destination
			// sync sees only one destination's contents, so it can't prove a
			// record is broken across all its destinations.
			//
			// Pagination: a site that's been backing up daily for years can
			// accumulate thousands of records. Loading everything in one query
			// would spike memory and blow PHP's memory_limit on shared hosts.
			// Pull a page at a time (1000 rows ≈ a few MB of in-memory rows),
			// process, and move on. We stop when a short page is returned
			// (less than full page size) — that means we've reached the end
			// of the table.
			$page_size = 1000;
			$page_off  = 0;
			while ( true ) {
				$batch = BackupRecord::all( $page_size, $page_off, null );
				if ( ! $batch ) break;

				foreach ( $batch as $rec ) {
					if ( ! in_array( $rec->status, [ 'success', 'failed', 'skipped' ], true ) ) continue;
					$dests = (array) $rec->destinations;
					if ( ! $dests ) continue;

					$scanned_present  = false;
					$scanned_missing  = false;
					foreach ( $dests as $did ) {
						$did = (string) $did;
						if ( ! isset( $scanned_by_dest[ $did ] ) ) continue;
						if ( isset( $scanned_by_dest[ $did ][ $rec->ext_id ] ) ) {
							$scanned_present = true;
							break;
						}
						$scanned_missing = true;
					}
					if ( $scanned_present )       continue; // Still on at least one destination — fine.
					if ( ! $scanned_missing )     continue; // None of its destinations were reachable — abstain.

					$broken_ids[] = $rec->ext_id;
				}

				// Short page → we've reached the last batch.
				if ( count( $batch ) < $page_size ) break;
				$page_off += $page_size;
			}
		}

		// Store the broken set in a transient so the list endpoint can flag
		// rows in its response. We keep it for 12 hours: long enough that a
		// user who runs sync overnight can come back in the morning and see
		// the warnings, short enough that a transient that becomes stale
		// (e.g. user re-attaches the destination) self-heals on the next sync.
		// If broken_ids is empty we clear the transient so stale flags from a
		// previous run don't linger.
		if ( ! $only_dest_id ) {
			if ( $broken_ids ) {
				set_transient( 'backreload_broken_records', $broken_ids, 12 * HOUR_IN_SECONDS );
			} else {
				delete_transient( 'backreload_broken_records' );
			}
		}

		// One summary log line so admins can audit sync activity from Logs.
		$this->plugin->get( 'logger' )->info(
			sprintf(
				'Destination sync ran: imported %d, skipped %d, errors %d, broken %d',
				count( $imported ), count( $skipped ), count( $errors ), count( $broken_ids )
			),
			[
				'imported_ids' => array_column( $imported, 'ext_id' ),
				'broken_ids'   => $broken_ids,
			]
		);

		return $this->ok( [
			'imported' => $imported,
			'skipped'  => $skipped,
			'errors'   => $errors,
			'broken'   => $broken_ids,
		] );
	}

	/**
	 * Turn a `list_backups()` entry into a BackupRecord. Shared by the
	 * manual-pick import (import_source_commit) and the bulk sync
	 * (sync_from_destinations).
	 *
	 * Throws RuntimeException with a user-readable message on validation
	 * failure — callers turn that into a 400 response or a per-entry skip.
	 *
	 * @param array<string,mixed>                    $entry  One element from $handler->list_backups()
	 * @param \Backreload\Models\DestinationRecord     $dest
	 */
	private function import_destination_entry( array $entry, \Backreload\Models\DestinationRecord $dest ): BackupRecord {
		$ext_id   = (string) ( $entry['ext_id'] ?? '' );
		$dest_id  = $dest->ext_id;
		$manifest = is_array( $entry['manifest'] ?? null ) ? $entry['manifest'] : null;

		if ( $manifest && ! empty( $manifest['files'] ) ) {
			$files = [];
			foreach ( (array) $manifest['files'] as $f ) {
				$name = (string) ( $f['name'] ?? '' );
				if ( ! $this->is_safe_remote_name( $name ) ) {
					throw new \RuntimeException( esc_html(  "Manifest contains an unsafe part name: {$name}", 400  )  );
				}
				$files[] = [
					'name'        => $name,
					'category'    => (string) ( $f['category'] ?? '' ),
					'size'        => (int) ( $f['size'] ?? 0 ),
					'checksum'    => (string) ( $f['checksum'] ?? '' ),
					'encrypted'   => ! empty( $f['encrypted'] ),
					'uploaded_to' => [ $dest_id ],
				];
			}
			if ( ! $files ) {
				throw new \RuntimeException( esc_html(  'The backup manifest lists no files.', 400  )  );
			}
			$rec = BackupRecord::create( [
				'ext_id'       => $ext_id,
				'type'         => (string) ( $manifest['type'] ?? 'full' ),
				'kind'         => (string) ( $manifest['kind'] ?? 'manual' ),
				'status'       => 'success',
				'components'   => array_values( (array) ( $manifest['components'] ?? [] ) ),
				'destinations' => [ $dest_id ],
				'encrypted'    => ! empty( $manifest['encrypted'] ),
				'source_url'   => (string) ( $manifest['source_url'] ?? '' ) ?: null,
				'source_path'  => (string) ( $manifest['source_path'] ?? '' ) ?: null,
			] );
			$rec->files        = $files;
			$rec->size         = (int) ( $manifest['size'] ?? 0 ) ?: array_sum( array_column( $files, 'size' ) );
			$rec->created_at   = (string) ( $manifest['created_at'] ?? '' ) ?: $rec->created_at;
			$rec->completed_at = (string) ( $manifest['completed_at'] ?? '' ) ?: current_time( 'mysql', true );
			$rec->save();
		} else {
			// No manifest (older backup or an orphan whose manifest never
			// landed) — classify the listed parts by filename.
			$rec = BackupRecord::create( [
				'ext_id'       => $ext_id,
				'type'         => 'full',
				'status'       => 'pending',
				'components'   => [],
				'destinations' => [ $dest_id ],
				'encrypted'    => false,
			] );
			$files = [];
			foreach ( (array) ( $entry['parts'] ?? [] ) as $p ) {
				$name = (string) ( $p['name'] ?? '' );
				if ( ! $this->is_safe_remote_name( $name ) ) continue;
				$cls = $this->classify_import_part( basename( $name ) );
				if ( ! $cls ) continue;
				$files[] = [
					'name'        => $name,
					'category'    => $cls[0],
					'size'        => (int) ( $p['size'] ?? 0 ),
					'checksum'    => '',
					'encrypted'   => $cls[1],
					'uploaded_to' => [ $dest_id ],
				];
			}
			if ( ! $files ) {
				$rec->delete();
				throw new \RuntimeException( esc_html(  'No recognizable backup parts in that folder.', 400  )  );
			}
			$rec->files        = $files;
			$this->recompute_import_meta( $rec );
			$rec->status       = 'success';
			$rec->completed_at = current_time( 'mysql', true );
			$rec->save();
		}

		$this->plugin->get( 'logger' )->info(
			"Imported backup {$ext_id} from destination {$dest->name}",
			[],
			$rec->ext_id
		);
		return $rec;
	}

	/**
	 * Whether a destination-relative file name is safe to use as a path.
	 * Rejects absolute paths, backslashes, null bytes, `..` traversal, and
	 * dotfile segments — the guard for names that arrive from an untrusted
	 * source (an imported manifest, a remote directory listing).
	 *
	 * Dotfile reject: a backup's legitimate parts never start with `.`
	 * (they're `db.sql.gz`, `uploads-1.zip`, …). A malicious manifest could
	 * claim `.htaccess` / `.user.ini` / `.htpasswd` as a part. If the import
	 * path then writes that into the per-backup folder under
	 * `wp-content/backreload-backups/bk_*`, Apache / LiteSpeed honor `.htaccess` for
	 * files served from that directory — giving the attacker URL rewrites,
	 * auth bypass, PHP-engine swaps, etc. scoped to that folder. Reject up
	 * front.
	 */
	private function is_safe_remote_name( string $name ): bool {
		return '' !== $name
			&& '/' !== ( $name[0] ?? '' )
			&& '.' !== ( $name[0] ?? '' )
			&& false === strpos( $name, '\\' )
			&& false === strpos( $name, "\0" )
			&& ! preg_match( '#(^|/)\.\.?(/|$)#', $name )
			&& ! preg_match( '#/\.#', $name );
	}

	/** First connected Local Storage destination, or null. */
	private function find_connected_local(): ?\Backreload\Models\DestinationRecord {
		foreach ( \Backreload\Models\DestinationRecord::all() as $d ) {
			if ( 'local' === $d->type && 'connected' === $d->status ) {
				return $d;
			}
		}
		return null;
	}

	/**
	 * Resolve the on-disk directory for a Local Storage destination, healing a
	 * stale/foreign path.
	 *
	 * The destination persists an ABSOLUTE path in its config. That path is
	 * correct on the site where it was set, but the database carries it across a
	 * migration or host move — so on the new server it points at the OLD host's
	 * filesystem (e.g. /home/<source-account>/.../uploads/backreload), which
	 * doesn't exist and can't be created here. The symptom users hit is
	 * "Storage path is not writable" showing the source server's path.
	 *
	 * We therefore trust the stored path only when it is a real, writable
	 * directory on THIS server; otherwise we fall back to the runtime-derived
	 * uploads location (Storage::root(), built from wp_get_upload_dir()) and
	 * persist it back so the Destinations UI and every later operation agree.
	 */
	private function local_storage_dir( \Backreload\Models\DestinationRecord $local ): string {
		$configured = rtrim( (string) ( $local->config['path'] ?? '' ), '/\\' );
		$dir        = \Backreload\Storage::usable_local_dir( $configured );
		// Self-heal the stored path when we had to fall back, so the Destinations
		// UI and every later operation agree on the correct location.
		if ( $dir !== $configured && '' !== (string) $local->ext_id ) {
			$cfg = (array) $local->config;
			$cfg['path'] = $dir;
			$local->config = $cfg;
			$local->save();
		}
		return $dir;
	}

	/**
	 * Re-derive an imported record's type / components / size / encrypted flag
	 * from whatever parts are currently in its files[].
	 */
	private function recompute_import_meta( BackupRecord $rec ): void {
		$entries = (array) $rec->files;
		$cats    = [];
		foreach ( $entries as $f ) {
			$cats[ (string) ( $f['category'] ?? '' ) ] = true;
		}
		if ( isset( $cats['legacy'] ) ) {
			$rec->type       = 'full';
			$rec->components = [ 'db', 'uploads', 'themes', 'plugins' ];
		} else {
			$has_db    = isset( $cats['db'] );
			$file_cats = array_intersect( [ 'uploads', 'themes', 'plugins', 'mu', 'others' ], array_keys( $cats ) );
			$rec->type = ( $has_db && $file_cats ) ? 'full' : ( $has_db ? 'database' : 'files' );
			$components = $has_db ? [ 'db' ] : [];
			foreach ( [ 'uploads', 'themes', 'plugins', 'mu', 'others' ] as $c ) {
				if ( isset( $cats[ $c ] ) ) {
					$components[] = $c;
				}
			}
			$rec->components = $components;
		}
		$rec->size      = array_sum( array_map( static fn( $f ) => (int) ( $f['size'] ?? 0 ), $entries ) );
		$rec->encrypted = (bool) array_filter( $entries, static fn( $f ) => ! empty( $f['encrypted'] ) );
	}

	/**
	 * Classify an uploaded import file by its name.
	 *
	 * @return array{0:string,1:bool}|null  [ category, is_encrypted ] or null if
	 *                                      the name isn't a recognizable part.
	 */
	private function classify_import_part( string $name ): ?array {
		$enc = false;
		$n   = $name;
		if ( preg_match( '/\.enc$/i', $n ) ) {
			$enc = true;
			$n   = (string) preg_replace( '/\.enc$/i', '', $n );
		}
		$n = strtolower( $n );

		if ( preg_match( '/^(?:db|database)\.sql(?:\.gz)?$/', $n ) ) {
			return [ 'db', $enc ];
		}
		// Full-fidelity import: plugin/theme/mu-plugin parts are accepted and
		// restored (unless this is a data-only build — data_only_mode() — in
		// which case the restore engine skips code components regardless).
		if ( preg_match( '/^plugins(?:-\d+)?\.zip$/', $n ) ) {
			return [ 'plugins', $enc ];
		}
		if ( preg_match( '/^themes(?:-\d+)?\.zip$/', $n ) ) {
			return [ 'themes', $enc ];
		}
		if ( preg_match( '/^mu(?:-plugins)?(?:-\d+)?\.zip$/', $n ) ) {
			return [ 'mu', $enc ];
		}
		if ( preg_match( '/^uploads(?:-\d+)?\.zip$/', $n ) ) {
			return [ 'uploads', $enc ];
		}
		if ( preg_match( '/^others?(?:-\d+)?\.zip$/', $n ) ) {
			return [ 'others', $enc ];
		}
		// Any other .zip → treat as a single full (legacy) archive.
		if ( preg_match( '/\.zip$/', $n ) ) {
			return [ 'legacy', $enc ];
		}
		return null;
	}

	/**
	 * Read the head of a (gzipped) SQL dump and pull out the original site URL
	 * from the wp_options siteurl/home row. Best-effort — returns null on miss.
	 */
	private function sniff_source_url_from_db( string $path ): ?string {
		$blob   = '';
		$is_gz  = ( 'gz' === strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ) );
		$budget = 5000000; // stop after ~5 MB decompressed — siteurl is dumped early.

		if ( $is_gz ) {
			$h = @gzopen( $path, 'rb' );
			if ( ! $h ) {
				return null;
			}
			$read = 0;
			while ( ! gzeof( $h ) && $read < $budget ) {
				$b = gzread( $h, 131072 );
				if ( false === $b || '' === $b ) {
					break;
				}
				$blob .= $b;
				$read += strlen( $b );
				if ( preg_match( "/'(?:siteurl|home)',\s*'([^']+)'/", $blob ) ) {
					break;
				}
			}
			gzclose( $h );
		} else {
			$blob = (string) file_get_contents( $path, false, null, 0, $budget );
		}

		if ( preg_match( "/'(?:siteurl|home)',\s*'([^']+)'/", $blob, $m ) ) {
			return rtrim( $m[1], '/' );
		}
		return null;
	}

	/** Delete import staging files older than a day (abandoned uploads). */
	private function prune_import_staging( string $dir ): void {
		$tmp = trailingslashit( $dir ) . '.backreload-import-tmp';
		if ( ! is_dir( $tmp ) ) {
			return;
		}
		foreach ( (array) glob( $tmp . '/*.part' ) as $f ) {
			if ( is_file( $f ) && ( time() - (int) @filemtime( $f ) ) > DAY_IN_SECONDS ) {
				@unlink( $f ); // 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.
			}
		}
	}

	/**
	 * Stream a backup archive to the browser, or list the parts of a multi-zip backup.
	 *
	 * Query params:
	 *   - part=N    : stream the N-th file (0-indexed) from files[]
	 *   - file=NAME : stream the file with that name from files[]
	 *
	 * If the backup is multi-zip and neither is provided, returns a JSON listing
	 * of available parts so the frontend can offer a per-part picker.
	 *
	 * For legacy backups (files[] empty) we stream {ext_id}.zip directly.
	 */
	public function download( WP_REST_Request $req ) {
		$rec = BackupRecord::find( (string) $req['id'] );
		if ( ! $rec ) {
			return $this->error( 'not_found', __( 'Backup not found', 'backreload' ), 404 );
		}

		$part = $req->get_param( 'part' );
		$file = (string) ( $req->get_param( 'file' ) ?? '' );

		// Resolve the filename to stream.
		$target_name = null;
		if ( ! empty( $rec->files ) ) {
			// Multi-zip (or imported legacy with files[] populated).
			if ( null !== $part && '' !== $part ) {
				$i = (int) $part;
				if ( isset( $rec->files[ $i ]['name'] ) ) {
					$target_name = (string) $rec->files[ $i ]['name'];
				}
			} elseif ( '' !== $file ) {
				foreach ( $rec->files as $f ) {
					if ( ( $f['name'] ?? '' ) === $file ) {
						$target_name = $file;
						break;
					}
				}
			}

			// Single-part archive → stream straight away. Multiple parts with no
			// selection → return the manifest so the UI can offer a picker.
			if ( ! $target_name ) {
				if ( 1 === count( $rec->files ) ) {
					$target_name = (string) ( $rec->files[0]['name'] ?? '' );
				} else {
					$parts = [];
					foreach ( $rec->files as $i => $f ) {
						$parts[] = [
							'index'    => $i,
							'name'     => (string) ( $f['name'] ?? '' ),
							'category' => (string) ( $f['category'] ?? '' ),
							'size'     => (int) ( $f['size'] ?? 0 ),
						];
					}
					return $this->ok( [ 'multi' => true, 'parts' => $parts ] );
				}
			}
		} else {
			// Pre-rewrite legacy: single zip named after the ext_id.
			$target_name = $rec->ext_id . ( $rec->encrypted ? '.zip.enc' : '.zip' );
		}

		if ( ! $target_name ) {
			return $this->error( 'bad_request', 'No file resolved for download.', 400 );
		}

		// SECURITY: validate the resolved name before touching the filesystem.
		// Even though `name` comes from the DB, treat it as untrusted — a
		// crafted import or future bug could pollute it. Reject absolute paths,
		// backslashes (Windows), null bytes, and `..` segments outright.
		if (
			str_starts_with( $target_name, '/' )
			|| false !== strpos( $target_name, "\\" )
			|| false !== strpos( $target_name, "\0" )
			|| preg_match( '#(^|/)\.\.(/|$)#', $target_name )
		) {
			return $this->error( 'bad_path', 'Refusing to serve a file with a suspicious name.', 400 );
		}

		// Resolve a source for the requested file in this order:
		//   1. A Local destination that holds it on disk — stream directly,
		//      zero copy.
		//   2. Any connected non-paused REMOTE destination that holds it —
		//      download to a temp file, stream that, clean up after.
		//
		// Step 2 is the recent fix: pre-this-change the endpoint refused to
		// download anything that wasn't on Local, which made the Download
		// button useless for users whose only working destination is FTP /
		// SFTP / S3. The user reported exactly this — three backups all on
		// FTP, Download button always 404'd.
		$local_path = null;
		$temp_path  = null;
		$cleanup    = static function () use ( &$temp_path ) {
			if ( $temp_path && is_file( $temp_path ) ) {
				@unlink( $temp_path ); // 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.
			}
		};
		try {
			// Tier 1: local copy already on disk.
			foreach ( \Backreload\Models\DestinationRecord::all() as $d ) {
				if ( 'local' !== $d->type ) continue;
				$root = realpath( (string) ( $d->config['path'] ?? '' ) );
				if ( ! $root ) continue;
				$candidate = trailingslashit( $root ) . $target_name;
				if ( ! is_file( $candidate ) ) continue;
				$resolved = realpath( $candidate );
				if ( ! $resolved ) continue;
				// Must be strictly inside the destination root.
				if ( 0 !== strpos( $resolved, trailingslashit( $root ) ) ) {
					continue; // escaped — refuse this match, keep looking
				}
				$local_path = $resolved;
				break;
			}

			// Tier 2: fall back to a remote destination. Only consider
			// destinations that this specific backup record was uploaded to
			// (the record's `destinations` array) AND that are currently
			// reachable (status=connected, not paused). We pull bytes via
			// the destination handler's download() into a wp_tempnam()
			// path, then stream that path.
			if ( ! $local_path ) {
				$mgr = $this->plugin->get( 'destinations' );
				$uploaded_to = [];
				if ( ! empty( $rec->files ) ) {
					foreach ( $rec->files as $f ) {
						if ( ( $f['name'] ?? '' ) !== $target_name ) continue;
						$uploaded_to = (array) ( $f['uploaded_to'] ?? [] );
						break;
					}
				}
				// Fall back to the backup-record-level destinations[] if the
				// per-file uploaded_to is empty (older records / interrupted
				// upload bookkeeping).
				if ( empty( $uploaded_to ) ) {
					$uploaded_to = (array) $rec->destinations;
				}
				foreach ( $uploaded_to as $dest_id ) {
					$d = \Backreload\Models\DestinationRecord::find( $dest_id );
					if ( ! $d ) continue;
					if ( 'connected' !== $d->status ) continue;
					if ( ! empty( $d->paused ) ) continue;
					if ( 'local' === $d->type ) continue; // already tried
					try {
						$handler = $mgr->handler( $d->type );
					} catch ( \Throwable $e ) { continue; }
					// Stream into a temp file. wp_tempnam handles writable-
					// path discovery + cleanup-friendly naming.
					if ( ! function_exists( 'wp_tempnam' ) ) {
						require_once ABSPATH . 'wp-admin/includes/file.php';
					}
					$temp_path = wp_tempnam( 'backreload-dl-' . basename( $target_name ) );
					if ( ! $temp_path ) continue;
					try {
						$ok = $handler->download( $d, $target_name, $temp_path );
					} catch ( \Throwable $e ) {
						$this->plugin->get( 'logger' )->warn(
							"Download fallback failed via {$d->name}: " . $e->getMessage(),
							[ 'file' => $target_name ],
							$rec->ext_id
						);
						$ok = false;
					}
					if ( $ok && is_file( $temp_path ) && filesize( $temp_path ) > 0 ) {
						$local_path = $temp_path;
						break;
					}
					// Try the next destination.
					@unlink( $temp_path ); // 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.
					$temp_path = null;
				}
			}

			if ( ! $local_path ) {
				return $this->error(
					'no_source',
					'Could not download, no Local copy and no reachable remote destination holds this backup. Reconnect/resume one of the destinations that uploaded this backup, or use Sync to refresh.',
					404
				);
			}

			// Stream the file.
			while ( ob_get_level() > 0 ) ob_end_clean();
			nocache_headers();
			header( 'Content-Type: application/octet-stream' );
			header( 'Content-Disposition: attachment; filename="' . basename( $target_name ) . '"' );
			header( 'Content-Length: ' . filesize( $local_path ) );
			readfile( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Streams a backup archive to the browser; readfile streams without buffering the whole file into memory.
		} finally {
			// register_shutdown_function so the temp file is deleted even if
			// readfile() exits unexpectedly mid-stream (broken pipe, etc).
			if ( $temp_path ) {
				register_shutdown_function( $cleanup );
			}
		}
		exit;
	}

	public function index( WP_REST_Request $req ) {
		$type   = (string) $req->get_param( 'type' ) ?: null;
		$limit  = max( 1, min( 200, (int) ( $req->get_param( 'limit' ) ?: 50 ) ) );
		$offset = max( 0, (int) $req->get_param( 'offset' ) );

		// Pull the broken-record set written by the most recent sync. We flip
		// it to a hash so the row-decorating loop below is O(1) per record.
		// If no sync has run since plugin install, the transient is missing
		// → empty set → no rows are flagged.
		$broken = get_transient( 'backreload_broken_records' );
		$broken = is_array( $broken ) ? array_flip( $broken ) : [];

		$items = array_map(
			static function ( $r ) use ( $broken ) {
				$row = $r->to_array();
				$row['missing_on_destination'] = isset( $broken[ $r->ext_id ] );
				return $row;
			},
			// Hide kind='migrating' rows. The migration push engine creates
			// a BackupRecord behind the scenes to drive the build phase;
			// it's an implementation detail, not a restorable backup the
			// user should see here. Migrate page surfaces them separately.
			BackupRecord::all( $limit, $offset, $type, 'migrating' )
		);
		return $this->ok( [
			'items'         => $items,
			'broken_count'  => count( $broken ),
		] );
	}

	public function show( WP_REST_Request $req ) {
		$rec = BackupRecord::find( (string) $req['id'] );
		if ( ! $rec ) {
			return $this->error( 'not_found', __( 'Backup not found', 'backreload' ), 404 );
		}
		$broken = get_transient( 'backreload_broken_records' );
		$broken = is_array( $broken ) ? array_flip( $broken ) : [];
		$row    = $rec->to_array();
		$row['missing_on_destination'] = isset( $broken[ $rec->ext_id ] );
		return $this->ok( $row );
	}

	public function create( WP_REST_Request $req ) {
		$type         = $req->get_param( 'type' ) ?: 'full';
		$components   = (array) ( $req->get_param( 'components' ) ?: [] );
		$destinations = (array) ( $req->get_param( 'destinations' ) ?: [] );
		$async        = false !== $req->get_param( 'async' );  // default true

		$engine = $this->plugin->get( 'engine' );
		$rec    = $engine->create_pending( [
			'type'         => $type,
			'components'   => $components ?: null,
			'destinations' => $destinations ?: null,
		] );

		if ( $async ) {
			$engine->dispatch_async( $rec );
			return $this->ok( $rec->to_array(), 202 );
		}

		$engine->execute( $rec );
		return $this->ok( $rec->to_array(), 201 );
	}

	public function destroy( WP_REST_Request $req ) {
		$rec = BackupRecord::find( (string) $req['id'] );
		if ( ! $rec ) {
			return $this->error( 'not_found', __( 'Backup not found', 'backreload' ), 404 );
		}
		// Remove remote archive files in addition to the DB row. Previously
		// this would leave gigabytes orphaned on every destination.
		$rec->delete_with_remote_files();
		return $this->ok( [ 'deleted' => true ] );
	}

	/**
	 * POST /backups/cleanup-broken
	 *
	 * Sweep out DB rows that the most recent sync flagged as "missing on
	 * destination" — backups whose folders the user deleted directly on the
	 * destination (over SSH/FTP/S3 console) and which now have no surviving
	 * copy anywhere. We delete DB-only (no destination calls) because there
	 * is nothing left to delete remotely; the destinations have already
	 * confirmed the folders are gone.
	 *
	 * Safety:
	 *   - We trust the transient written by sync_from_destinations(): it was
	 *     populated under the same `manage_options` capability check this
	 *     endpoint requires, and only ids it judges genuinely broken make
	 *     it in.
	 *   - Even so, we re-fetch each record before deleting and only delete
	 *     records that are still in a terminal state. A running backup
	 *     that races us in here would be left alone.
	 *   - We clear the transient on success so the warning chips disappear
	 *     immediately on the next list refresh.
	 */
	public function cleanup_broken( WP_REST_Request $req ) {
		$broken = get_transient( 'backreload_broken_records' );
		if ( ! is_array( $broken ) || ! $broken ) {
			return $this->ok( [ 'deleted' => 0, 'ids' => [] ] );
		}

		$deleted = [];
		$skipped = [];
		foreach ( $broken as $ext_id ) {
			$ext_id = (string) $ext_id;
			$rec    = BackupRecord::find( $ext_id );
			if ( ! $rec ) {
				$skipped[] = [ 'ext_id' => $ext_id, 'reason' => 'already gone' ];
				continue;
			}
			// Defence in depth: never nuke a non-terminal record from this
			// path. If something went mid-flight between sync and cleanup,
			// the user can stop it the normal way.
			if ( ! in_array( $rec->status, [ 'success', 'failed', 'skipped' ], true ) ) {
				$skipped[] = [ 'ext_id' => $ext_id, 'reason' => 'in flight, refusing to delete' ];
				continue;
			}
			// DB-only delete. The whole point of this path is that the
			// destinations no longer hold these files, so calling
			// delete_with_remote_files() would log a flood of "not found"
			// errors against destinations that are doing exactly what we
			// expect.
			$rec->delete();
			$deleted[] = $ext_id;
		}

		delete_transient( 'backreload_broken_records' );

		$this->plugin->get( 'logger' )->info(
			sprintf( 'Cleaned up %d broken record(s) (DB-only delete)', count( $deleted ) ),
			[ 'deleted_ids' => $deleted, 'skipped' => $skipped ]
		);

		return $this->ok( [
			'deleted' => count( $deleted ),
			'ids'     => $deleted,
			'skipped' => $skipped,
		] );
	}

	public function cancel( WP_REST_Request $req ) {
		$rec = BackupRecord::find( (string) $req['id'] );
		if ( ! $rec ) {
			return $this->error( 'not_found', __( 'Backup not found', 'backreload' ), 404 );
		}
		if ( ! in_array( $rec->status, [ 'pending', 'running' ], true ) ) {
			return $this->error( 'bad_state', "Backup is in '{$rec->status}' state and cannot be cancelled.", 400 );
		}

		// Atomic partial UPDATE on just the status column. A full $rec->save() would
		// race the engine: we hold a stale copy of `files`, `size`, etc., and the
		// engine has a fresh one mid-write. Last writer wins, so the engine's save
		// would clobber our cancel flag back to 'running' (and we'd erase the
		// engine's progress). flag_cancel() touches only `status`, so neither
		// side wipes the other's work.
		BackupRecord::flag_cancel( $rec->ext_id );

		// Fast-path finalize: if there's no live tick (no tick-mutex row),
		// nobody is going to flip 'cancelling' → 'cancelled' on its next
		// check_cancellation(). Finalize directly so the UI doesn't spin on
		// "Stopping…" for up to a full tick + retry-backoff. A tick that races
		// us in to claim the row will then bail on terminal status — safe.
		//
		// The lock is an option (since the TOCTOU fix); we treat anything
		// younger than the 5-minute TTL as live. 300 magic number mirrors
		// BackupEngine::TICK_LOCK_TTL_SECS.
		$lock_held_at = (int) get_option( 'backreload_tick_lock_' . $rec->ext_id, 0 );
		$tick_alive   = $lock_held_at > 0 && ( time() - $lock_held_at ) <= 300;
		if ( ! $tick_alive ) {
			$rec_db = BackupRecord::find( $rec->ext_id );
			if ( $rec_db && 'cancelling' === $rec_db->status ) {
				$rec_db->status       = 'cancelled';
				$rec_db->completed_at = current_time( 'mysql', true );
				$rec_db->save();
				$this->plugin->get( 'logger' )->warn( 'Cancel requested by user, finalized immediately (no live tick)', [], $rec_db->ext_id );
				return $this->ok( $rec_db->to_array() );
			}
		}

		$rec->status = 'cancelling'; // sync the in-memory copy for the response
		$this->plugin->get( 'logger' )->warn( 'Cancel requested by user', [], $rec->ext_id );
		return $this->ok( $rec->to_array() );
	}

	/**
	 * Resume a failed backup. Flips the status back to 'pending' (so the
	 * engine treats it as runnable) and re-dispatches via the async runner.
	 * Engine's per-category guards detect prior build state and skip rebuilds.
	 */
	public function resume( WP_REST_Request $req ) {
		$rec = BackupRecord::find( (string) $req['id'] );
		if ( ! $rec ) {
			return $this->error( 'not_found', __( 'Backup not found', 'backreload' ), 404 );
		}
		if ( ! in_array( $rec->status, [ 'failed', 'cancelled' ], true ) ) {
			return $this->error( 'bad_state', "Only failed or cancelled backups can be resumed. Current state: '{$rec->status}'.", 400 );
		}
		if ( empty( $rec->files ) ) {
			return $this->error( 'nothing_to_resume', 'This backup has no recorded progress; start a fresh backup instead.', 400 );
		}

		// Flip to pending and re-dispatch. The engine's existing files[] tracking
		// + per-category guards do the actual resumption work — no special
		// "resume mode" required; it's the same execute() path.
		$rec->status       = 'pending';
		$rec->error        = null;
		$rec->completed_at = null;
		$rec->save();

		$this->plugin->get( 'logger' )->info(
			'Resume requested, re-dispatching engine',
			[ 'existing_parts' => count( $rec->files ) ],
			$rec->ext_id
		);
		$this->plugin->get( 'engine' )->dispatch_async( $rec );
		return $this->ok( $rec->to_array(), 202 );
	}

	public function restore( WP_REST_Request $req ) {
		try {
			$ok = $this->plugin->get( 'restore' )->run( (string) $req['id'], [
				'components'    => (array) $req->get_param( 'components' ) ?: null,
				'destination'   => (string) $req->get_param( 'destination' ) ?: null,
				'passphrase'    => (string) $req->get_param( 'passphrase' ) ?: null,
				'migrate'       => (bool) $req->get_param( 'migrate' ),
				'source_url'    => (string) $req->get_param( 'source_url' )  ?: null,
				'target_url'    => (string) $req->get_param( 'target_url' )  ?: null,
				'source_path'   => (string) $req->get_param( 'source_path' ) ?: null,
				'target_path'   => (string) $req->get_param( 'target_path' ) ?: null,
				// Clean-restore: wipe target dirs before extraction so files
				// added/planted after the backup don't survive. Opt-in because
				// it's destructive — any content created since the backup
				// (uploads, plugin installs, theme tweaks) is permanently lost.
				'clean_restore' => (bool) $req->get_param( 'clean_restore' ),
			] );
			return $this->ok( [ 'restored' => $ok ] );
		} catch ( \Throwable $e ) {
			return $this->error( 'restore_failed', $e->getMessage(), 500 );
		}
	}
}
