<?php
/**
 * Siteway Migrator — Standalone Endpoint
 *
 * Works WITHOUT WordPress. Reads wp-config.php directly for DB credentials.
 * Place in the WordPress root directory (next to wp-config.php).
 *
 * Version: 2.2.0
 * Author: Siteway Oy
 *
 * Endpoints (via ?action= query param, auth: Bearer token):
 *   GET  ?action=info         — site metadata
 *   GET  ?action=db           — streaming SQL dump
 *   GET  ?action=files        — streaming tar of wp-content
 *   GET  ?action=core-files   — streaming tar of wp-admin, wp-includes, root PHP
 *   POST ?action=push         — receive tar of modified files, backup & apply
 *   GET  ?action=push-backups — list push backups
 *   POST ?action=undo-push    — restore files from backup
 *   POST ?action=cleanup      — delete all push backups
 *   POST ?action=reinstall-core — download fresh WP core from wordpress.org
 *   POST ?action=uninstall    — remove this file from the server
 */

// ── Auth key (injected at install time) ─────────────────────────────

define( 'SITEWAY_MIGRATOR_KEY', '96ade897ee381a7ad9f157ef488664cbb462b9857d29c8d242ba5f39846d4c67' );

// ── Bootstrap ───────────────────────────────────────────────────────

define( 'SITEWAY_MIGRATOR', true );
define( 'SITEWAY_MIGRATOR_VERSION', '2.2.0' );

$GLOBALS['sm_abspath'] = rtrim( str_replace( '\\', '/', __DIR__ ), '/' ) . '/';

// Parse wp-config.php for DB credentials.
$config = sm_parse_wp_config( $GLOBALS['sm_abspath'] );
if ( ! $config ) {
	sm_json_error( 'Cannot parse wp-config.php', 500 );
}
$GLOBALS['sm_config'] = $config;

// ── Auth check ──────────────────────────────────────────────────────

$auth_header = '';
if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
	$auth_header = $_SERVER['HTTP_AUTHORIZATION'];
} elseif ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
	$auth_header = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
} elseif ( function_exists( 'apache_request_headers' ) ) {
	$headers = apache_request_headers();
	if ( isset( $headers['Authorization'] ) ) {
		$auth_header = $headers['Authorization'];
	}
}

if ( stripos( $auth_header, 'Bearer ' ) !== 0 ) {
	sm_json_error( 'Missing Bearer token.', 401 );
}

$token = trim( substr( $auth_header, 7 ) );
if ( empty( SITEWAY_MIGRATOR_KEY ) || strlen( SITEWAY_MIGRATOR_KEY ) < 32 || ! hash_equals( SITEWAY_MIGRATOR_KEY, $token ) ) {
	sm_json_error( 'Invalid token.', 403 );
}

// ── Routing ─────────────────────────────────────────────────────────

$action = isset( $_GET['action'] ) ? $_GET['action'] : '';

switch ( $action ) {
	case 'info':
		sm_action_info();
		break;
	case 'db':
		sm_action_db();
		break;
	case 'list':
		sm_action_list();
		break;
	case 'files':
		sm_action_files();
		break;
	case 'core-files':
		sm_action_core_files();
		break;
	case 'push':
		sm_action_push();
		break;
	case 'push-backups':
		sm_action_push_backups();
		break;
	case 'undo-push':
		sm_action_undo_push();
		break;
	case 'cleanup':
		sm_action_cleanup();
		break;
	case 'reinstall-core':
		sm_action_reinstall_core();
		break;
	case 'uninstall':
		sm_action_uninstall();
		break;
	default:
		sm_json_error( 'Unknown action. Valid: info, db, files, list, core-files, push, push-backups, undo-push, cleanup, reinstall-core, uninstall', 400 );
}

// ── wp-config.php parser ────────────────────────────────────────────

function sm_parse_wp_config( $abspath ) {
	$config_file = $abspath . 'wp-config.php';
	if ( ! file_exists( $config_file ) ) {
		return null;
	}

	$content = file_get_contents( $config_file );
	if ( ! $content ) {
		return null;
	}

	// Some wp-config.php files include a separate file for DB constants.
	// Check for require/include of another config file before wp-settings.php.
	if ( preg_match( "/(?:require|include)(?:_once)?\s*\(\s*['\"](.+?)['\"]\s*\)/", $content, $inc_match ) ) {
		$inc_path = $inc_match[1];
		// Resolve relative paths.
		if ( $inc_path[0] !== '/' && strpos( $inc_path, ':\\' ) === false ) {
			$inc_path = $abspath . $inc_path;
		}
		// Replace __DIR__ and dirname(__FILE__).
		$inc_path = str_replace( [ '__DIR__', "dirname(__FILE__)", "dirname( __FILE__ )" ], rtrim( $abspath, '/' ), $inc_path );
		if ( file_exists( $inc_path ) ) {
			$content .= "\n" . file_get_contents( $inc_path );
		}
	}

	$result = [
		'db_name'     => null,
		'db_user'     => null,
		'db_password' => null,
		'db_host'     => 'localhost',
		'table_prefix' => 'wp_',
	];

	// Extract define() constants.
	$constants = [ 'DB_NAME' => 'db_name', 'DB_USER' => 'db_user', 'DB_PASSWORD' => 'db_password', 'DB_HOST' => 'db_host' ];
	foreach ( $constants as $const => $key ) {
		// Match: define( 'DB_NAME', 'value' ) — with flexible whitespace and quote types.
		if ( preg_match( "/define\s*\(\s*['\"]" . $const . "['\"]\s*,\s*['\"](.+?)['\"]\s*\)/", $content, $m ) ) {
			$result[ $key ] = $m[1];
		}
	}

	// Extract $table_prefix.
	if ( preg_match( "/\\\$table_prefix\s*=\s*['\"](.+?)['\"]/", $content, $m ) ) {
		$result['table_prefix'] = $m[1];
	}

	// Extract WP_CONTENT_DIR if custom.
	$result['wp_content_dir'] = $abspath . 'wp-content';
	if ( preg_match( "/define\s*\(\s*['\"]WP_CONTENT_DIR['\"]\s*,\s*(.+?)\s*\)/", $content, $m ) ) {
		$val = trim( $m[1], "'\"\t\n\r " );
		// Handle concatenation like dirname(__FILE__) . '/content'
		if ( strpos( $val, '.' ) !== false ) {
			$val = str_replace( [ '__DIR__', "dirname(__FILE__)", "dirname( __FILE__ )" ], rtrim( $abspath, '/' ), $val );
			$val = preg_replace( "/['\"]?\s*\.\s*['\"]?/", '', $val );
		}
		if ( is_dir( $val ) ) {
			$result['wp_content_dir'] = rtrim( $val, '/' );
		}
	}

	// Validate required fields.
	if ( ! $result['db_name'] || ! $result['db_user'] || $result['db_password'] === null ) {
		return null;
	}

	return $result;
}

// ── Database helper ─────────────────────────────────────────────────

function sm_db_connect() {
	$c = $GLOBALS['sm_config'];

	// Parse host:port or host:/socket.
	$host   = $c['db_host'];
	$port   = 3306;
	$socket = null;

	if ( strpos( $host, ':' ) !== false ) {
		list( $host, $extra ) = explode( ':', $host, 2 );
		if ( $extra[0] === '/' ) {
			$socket = $extra;
		} else {
			$port = (int) $extra;
		}
	}

	$db = @new mysqli( $host, $c['db_user'], $c['db_password'], $c['db_name'], $port, $socket );
	if ( $db->connect_error ) {
		sm_json_error( 'DB connection failed: ' . $db->connect_error, 500 );
	}

	$db->set_charset( 'utf8mb4' );
	return $db;
}

// ── Response helpers ────────────────────────────────────────────────

function sm_json_response( $data, $code = 200 ) {
	http_response_code( $code );
	header( 'Content-Type: application/json; charset=utf-8' );
	echo json_encode( $data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
	exit;
}

function sm_json_error( $message, $code = 400 ) {
	http_response_code( $code );
	header( 'Content-Type: application/json; charset=utf-8' );
	echo json_encode( [ 'error' => $message ], JSON_UNESCAPED_UNICODE );
	exit;
}

function sm_deactivate_plugin( $plugin_slug ) {
	$c  = $GLOBALS['sm_config'];
	$db = @new mysqli( $c['db_host'], $c['db_user'], $c['db_password'], $c['db_name'] );
	if ( $db->connect_error ) {
		return;
	}
	$db->set_charset( 'utf8mb4' );
	$prefix = $c['table_prefix'];
	$result = $db->query( "SELECT option_value FROM {$prefix}options WHERE option_name = 'active_plugins' LIMIT 1" );
	if ( ! $result || ! ( $row = $result->fetch_assoc() ) ) {
		$db->close();
		return;
	}
	$plugins = @unserialize( $row['option_value'] );
	if ( ! is_array( $plugins ) ) {
		$db->close();
		return;
	}
	$plugins = array_values( array_filter( $plugins, function( $p ) use ( $plugin_slug ) {
		return $p !== $plugin_slug;
	} ) );
	$new_value = $db->real_escape_string( serialize( $plugins ) );
	$db->query( "UPDATE {$prefix}options SET option_value = '{$new_value}' WHERE option_name = 'active_plugins'" );
	$db->close();
}

// ── GET ?action=info ────────────────────────────────────────────────

function sm_action_info() {
	$c  = $GLOBALS['sm_config'];
	$db = sm_db_connect();

	// Database size.
	$db_size = 0;
	$result  = $db->query( "SELECT table_name, data_length + index_length AS size FROM information_schema.TABLES WHERE table_schema = '" . $db->real_escape_string( $c['db_name'] ) . "'" );
	if ( $result ) {
		while ( $row = $result->fetch_assoc() ) {
			$db_size += (int) $row['size'];
		}
		$result->free();
	}

	// wp-content size.
	$exclude_from_size = [ 'cache', 'upgrade', 'updraft', 'node_modules', 'ai1wm-backups', 'backups', 'backup', 'siteway-backups' ];
	$wp_content_size   = sm_dir_size( $c['wp_content_dir'], $exclude_from_size );

	// Site URL and Home from DB.
	$prefix   = $db->real_escape_string( $c['table_prefix'] );
	$site_url = sm_get_option( $db, $prefix, 'siteurl' );
	$home_url = sm_get_option( $db, $prefix, 'home' );

	// Active plugins.
	$active_raw = sm_get_option( $db, $prefix, 'active_plugins' );
	$active_plugins = $active_raw ? @unserialize( $active_raw ) : [];
	if ( ! is_array( $active_plugins ) ) {
		$active_plugins = [];
	}

	// WP version from wp-includes/version.php.
	$wp_version = 'unknown';
	$version_file = $GLOBALS['sm_abspath'] . 'wp-includes/version.php';
	if ( file_exists( $version_file ) ) {
		$version_content = file_get_contents( $version_file );
		if ( preg_match( "/\\\$wp_version\s*=\s*['\"](.+?)['\"]/", $version_content, $m ) ) {
			$wp_version = $m[1];
		}
	}

	// Disk free.
	$disk_free = function_exists( 'disk_free_space' ) ? @disk_free_space( $GLOBALS['sm_abspath'] ) : null;

	// Is multisite?
	$is_multisite = false;
	$ms_check = file_get_contents( $GLOBALS['sm_abspath'] . 'wp-config.php' );
	if ( $ms_check && preg_match( "/define\s*\(\s*['\"]MULTISITE['\"]\s*,\s*true\s*\)/i", $ms_check ) ) {
		$is_multisite = true;
	}

	$db->close();

	sm_json_response( [
		'site_url'               => $site_url,
		'home_url'               => $home_url,
		'wp_version'             => $wp_version,
		'php_version'            => phpversion(),
		'db_size'                => $db_size,
		'wp_content_size'        => $wp_content_size,
		'disk_free'              => $disk_free,
		'php_max_execution_time' => (int) ini_get( 'max_execution_time' ),
		'upload_max_filesize'    => ini_get( 'upload_max_filesize' ),
		'active_plugins'         => $active_plugins,
		'table_prefix'           => $c['table_prefix'],
		'is_multisite'           => $is_multisite,
		'migrator_version'       => SITEWAY_MIGRATOR_VERSION,
		'standalone'             => true,
		'has_zip_archive'        => class_exists( 'ZipArchive' ),
	] );
}

// ── GET ?action=db ──────────────────────────────────────────────────

function sm_action_db() {
	$c  = $GLOBALS['sm_config'];
	$db = sm_db_connect();
	$mode   = $_GET['mode'] ?? 'full';
	$prefix = $c['table_prefix'];

	// Time tracking for safe chunking on shared hosts.
	$start_time = microtime( true );
	$max_exec   = (int) ini_get( 'max_execution_time' );
	$time_limit = isset( $_GET['time_limit'] ) ? (int) $_GET['time_limit'] : 0;
	if ( $time_limit > 0 ) {
		$safe_time = $time_limit;
	} elseif ( $max_exec > 0 ) {
		$safe_time = max( $max_exec - 5, 20 );
	} else {
		$safe_time = 9999;
	}

	// Resume support: skip to a specific table on subsequent chunks.
	$resume_from = isset( $_GET['resume_from'] ) ? $_GET['resume_from'] : '';
	$is_resume   = ! empty( $resume_from );

	@set_time_limit( 0 );
	@ignore_user_abort( false );

	while ( ob_get_level() ) {
		ob_end_clean();
	}

	// Fast mode: define tables to skip entirely, and tables to export structure only (no data).
	$skip_tables     = []; // Skip completely (no CREATE TABLE, no data)
	$skip_data_suffixes = []; // Export CREATE TABLE but no data
	$skip_revisions  = false;
	$skip_transients = false;

	if ( $mode === 'fast' ) {
		// Skip WP Staging clone tables entirely (full duplicate of the site).
		$skip_tables[] = 'wpstg%';

		// Tables where we keep structure but skip data (logs, caches, analytics — all regeneratable).
		$skip_data_suffixes = [
			'wf%',                    // Wordfence (scans, file lists, login logs)
			'actionscheduler_%',      // Action Scheduler (queued tasks)
			'post_smtp_log%',         // Post SMTP email logs
			'email_log',             // Email log
			'mainwp_child_%',        // MainWP change tracking
			'clarity_%',             // Microsoft Clarity analytics
			'wpr_%',                 // WP Rocket cache tables
			'redirection_404',       // Redirect 404 logs
			'redirection_logs',      // Redirect access logs
			'ewwwio_%',              // EWWW image optimization
			'imagify_%',             // Imagify image optimization
			'indexnow_%',            // IndexNow submission logs
			'laser_cache',           // Cache
			'yoast_indexable',       // Yoast SEO (regenerated on visit)
			'yoast_indexable_hierarchy',
			'yoast_seo_links',
			'wsal_%',                // WP Security Audit Log (huge audit data)
			'WP_SEO_%',             // WP SEO (404 logs, redirect logs)
			'seopress_%',           // SEOPress (analysis data, regeneratable)
		];

		$skip_revisions  = true;
		$skip_transients = true;
	}

	header( 'Content-Type: application/sql; charset=utf-8' );
	header( 'Content-Disposition: attachment; filename="' . $c['db_name'] . '.sql"' );
	header( 'X-Accel-Buffering: no' );
	header( 'Cache-Control: no-cache' );

	if ( ! $is_resume ) {
		echo "-- Siteway Migrator SQL Dump v2.2.0 (standalone)\n";
		echo "-- Database: " . $c['db_name'] . "\n";
		echo "-- Mode: " . $mode . "\n";
		echo "-- Generated: " . gmdate( 'Y-m-d H:i:s' ) . " UTC\n";
		echo "-- PHP " . phpversion() . "\n\n";
		echo "SET NAMES utf8mb4;\n";
		echo "SET foreign_key_checks = 0;\n";
		echo "SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';\n\n";
	}

	$tables_result = $db->query( 'SHOW TABLES' );
	$all_tables    = [];
	while ( $row = $tables_result->fetch_row() ) {
		$all_tables[] = $row[0];
	}
	$tables_result->free();

	$skipped_count  = 0;
	$nodata_count   = 0;

	// Classify tables into buckets: data tables first, skip-data second, skip-entirely last.
	// This ensures core WP tables are exported before a hard timeout kills the script.
	$tables_with_data = [];
	$tables_no_data   = [];
	$tables_skip      = [];

	foreach ( $all_tables as $table ) {
		$do_skip = false;
		foreach ( $skip_tables as $pattern ) {
			if ( sm_table_matches( $table, $pattern ) ) {
				$do_skip = true;
				break;
			}
		}
		if ( $do_skip ) {
			$tables_skip[] = $table;
			continue;
		}

		$skip_data = false;
		foreach ( $skip_data_suffixes as $suffix ) {
			if ( sm_table_matches( $table, $prefix . $suffix ) ) {
				$skip_data = true;
				break;
			}
		}
		if ( $skip_data ) {
			$tables_no_data[] = $table;
		} else {
			$tables_with_data[] = $table;
		}
	}

	// Process order: data tables first, structure-only second, skipped last.
	$tables = array_merge( $tables_with_data, $tables_no_data, $tables_skip );

	// If resuming, skip to the resume table.
	if ( $is_resume ) {
		$found    = false;
		$filtered = [];
		foreach ( $tables as $t ) {
			if ( ! $found ) {
				if ( $t === $resume_from ) {
					$found = true;
				} else {
					continue;
				}
			}
			$filtered[] = $t;
		}
		$tables = $filtered;
	}

	foreach ( $tables as $idx => $table ) {
		// Check time budget before starting each table.
		$elapsed = microtime( true ) - $start_time;
		if ( $elapsed >= $safe_time ) {
			echo "-- SITEWAY_RESUME: {$table}\n";
			flush();
			$db->close();
			exit;
		}

		// Reset execution timer per table.
		@set_time_limit( 30 );

		// Check if table should be skipped entirely.
		$do_skip = false;
		foreach ( $skip_tables as $pattern ) {
			if ( sm_table_matches( $table, $pattern ) ) {
				$do_skip = true;
				break;
			}
		}
		if ( $do_skip ) {
			$skipped_count++;
			echo "-- SKIPPED (fast mode): {$table}\n";
			continue;
		}

		// Table structure (always exported).
		$create_result = $db->query( "SHOW CREATE TABLE `{$table}`" );
		$create_row    = $create_result->fetch_row();
		echo "DROP TABLE IF EXISTS `{$table}`;\n";
		echo $create_row[1] . ";\n\n";
		$create_result->free();

		// Check if this table should have data skipped.
		$skip_data = false;
		foreach ( $skip_data_suffixes as $suffix ) {
			if ( sm_table_matches( $table, $prefix . $suffix ) ) {
				$skip_data = true;
				break;
			}
		}
		if ( $skip_data ) {
			$nodata_count++;
			echo "-- DATA SKIPPED (fast mode): {$table}\n\n";
			continue;
		}

		// Build WHERE clause for conditional row filtering.
		$where = '';
		if ( $skip_revisions && sm_table_matches( $table, $prefix . 'posts' ) && ! sm_table_matches( $table, $prefix . 'postmeta' ) ) {
			$where = "WHERE post_type != 'revision' AND post_type != 'auto-draft'";
		}
		if ( $skip_transients && sm_table_matches( $table, $prefix . 'options' ) ) {
			$where = "WHERE option_name NOT LIKE '_transient_%' AND option_name NOT LIKE '_site_transient_%'";
		}
		// Skip orphaned postmeta (for deleted revisions) — match only existing post IDs.
		if ( $skip_revisions && sm_table_matches( $table, $prefix . 'postmeta' ) ) {
			$where = "WHERE post_id IN (SELECT ID FROM `{$prefix}posts` WHERE post_type != 'revision' AND post_type != 'auto-draft')";
		}

		// Table data in batches — extended INSERT for faster import.
		$pk            = sm_get_primary_key( $db, $table );
		$batch_size    = 5000;
		$rows_per_stmt = 200;
		$binary_cols   = null; // Lazy-detected on first batch.
		$col_list      = '';

		if ( $pk && count( $pk ) === 1 ) {
			// ── Keyset pagination (single-column PK) ─────────────
			$pk_col         = $pk[0];
			$last_id        = null;
			$pk_field_index = null;

			while ( true ) {
				$conditions = [];
				if ( $where ) {
					$conditions[] = substr( $where, 6 ); // Strip leading "WHERE ".
				}
				if ( $last_id !== null ) {
					$conditions[] = "`{$pk_col}` > '" . $db->real_escape_string( $last_id ) . "'";
				}
				$full_where = ! empty( $conditions ) ? 'WHERE ' . implode( ' AND ', $conditions ) : '';
				$query      = "SELECT * FROM `{$table}` {$full_where} ORDER BY `{$pk_col}` ASC LIMIT {$batch_size}";

				$data_result = $db->query( $query );
				if ( ! $data_result || $data_result->num_rows === 0 ) {
					if ( $data_result ) $data_result->free();
					break;
				}

				// Detect binary columns and build column list on first batch.
				if ( $binary_cols === null ) {
					$fields      = $data_result->fetch_fields();
					$binary_cols = sm_detect_binary_columns( $fields );
					$columns     = [];
					foreach ( $fields as $fi => $field ) {
						$columns[] = '`' . $field->name . '`';
						if ( $field->name === $pk_col ) {
							$pk_field_index = $fi;
						}
					}
					$col_list = implode( ', ', $columns );
				}

				$row_buffer = [];
				while ( $row = $data_result->fetch_row() ) {
					$last_id    = $row[ $pk_field_index ];
					$values     = sm_format_row_values( $db, $row, $binary_cols );
					$row_buffer[] = '(' . implode( ', ', $values ) . ')';

					if ( count( $row_buffer ) >= $rows_per_stmt ) {
						echo "INSERT INTO `{$table}` ({$col_list}) VALUES " . implode( ",\n", $row_buffer ) . ";\n";
						$row_buffer = [];
					}
				}
				if ( ! empty( $row_buffer ) ) {
					echo "INSERT INTO `{$table}` ({$col_list}) VALUES " . implode( ",\n", $row_buffer ) . ";\n";
				}

				$count = $data_result->num_rows;
				$data_result->free();
				flush();
				@set_time_limit( 30 );

				if ( connection_aborted() ) {
					$db->close();
					exit;
				}

				// Check time budget after each batch.
				$elapsed = microtime( true ) - $start_time;
				if ( $elapsed >= $safe_time ) {
					$next_table = isset( $tables[ $idx + 1 ] ) ? $tables[ $idx + 1 ] : null;
					if ( $count < $batch_size && $next_table ) {
						echo "\n-- SITEWAY_RESUME: {$next_table}\n";
					} elseif ( $count < $batch_size ) {
						echo "\n";
						sm_db_footer( $mode, $skipped_count, $nodata_count );
					} else {
						echo "\n-- SITEWAY_RESUME: {$table}\n";
					}
					flush();
					$db->close();
					exit;
				}

				if ( $count < $batch_size ) {
					break;
				}
			}
		} else {
			// ── OFFSET pagination (composite PK or no PK) ────────
			$order_by = '';
			if ( $pk ) {
				$order_by = 'ORDER BY ' . implode( ', ', array_map( function( $col ) { return "`{$col}`"; }, $pk ) );
			} else {
				$order_by = 'ORDER BY 1';
			}
			$offset = 0;

			while ( true ) {
				$query = "SELECT * FROM `{$table}` {$where} {$order_by} LIMIT {$batch_size} OFFSET {$offset}";
				$data_result = $db->query( $query );
				if ( ! $data_result || $data_result->num_rows === 0 ) {
					if ( $data_result ) $data_result->free();
					break;
				}

				if ( $binary_cols === null ) {
					$fields      = $data_result->fetch_fields();
					$binary_cols = sm_detect_binary_columns( $fields );
					$columns     = [];
					foreach ( $fields as $field ) {
						$columns[] = '`' . $field->name . '`';
					}
					$col_list = implode( ', ', $columns );
				}

				$row_buffer = [];
				while ( $row = $data_result->fetch_row() ) {
					$values     = sm_format_row_values( $db, $row, $binary_cols );
					$row_buffer[] = '(' . implode( ', ', $values ) . ')';

					if ( count( $row_buffer ) >= $rows_per_stmt ) {
						echo "INSERT INTO `{$table}` ({$col_list}) VALUES " . implode( ",\n", $row_buffer ) . ";\n";
						$row_buffer = [];
					}
				}
				if ( ! empty( $row_buffer ) ) {
					echo "INSERT INTO `{$table}` ({$col_list}) VALUES " . implode( ",\n", $row_buffer ) . ";\n";
				}

				$count = $data_result->num_rows;
				$data_result->free();
				$offset += $batch_size;
				flush();
				@set_time_limit( 30 );

				if ( connection_aborted() ) {
					$db->close();
					exit;
				}

				$elapsed = microtime( true ) - $start_time;
				if ( $elapsed >= $safe_time ) {
					$next_table = isset( $tables[ $idx + 1 ] ) ? $tables[ $idx + 1 ] : null;
					if ( $count < $batch_size && $next_table ) {
						echo "\n-- SITEWAY_RESUME: {$next_table}\n";
					} elseif ( $count < $batch_size ) {
						echo "\n";
						sm_db_footer( $mode, $skipped_count, $nodata_count );
					} else {
						echo "\n-- SITEWAY_RESUME: {$table}\n";
					}
					flush();
					$db->close();
					exit;
				}

				if ( $count < $batch_size ) {
					break;
				}
			}
		}

		echo "\n";
	}

	sm_db_footer( $mode, $skipped_count, $nodata_count );
	$db->close();
	exit;
}

/** Output SQL footer with completion marker. */
function sm_db_footer( $mode, $skipped_count, $nodata_count ) {
	if ( $mode === 'fast' ) {
		echo "-- Fast mode: {$skipped_count} tables skipped, {$nodata_count} tables structure-only\n";
	}
	echo "SET foreign_key_checks = 1;\n";
	echo "-- SITEWAY_COMPLETE\n";
}

/** Check if a table name matches a pattern (supports trailing % wildcard). */
function sm_table_matches( $table, $pattern ) {
	if ( substr( $pattern, -1 ) === '%' ) {
		return strpos( $table, substr( $pattern, 0, -1 ) ) === 0;
	}
	return $table === $pattern;
}

/** Get primary key column(s) for a table. Returns array of column names or null. */
function sm_get_primary_key( $db, $table ) {
	$result = $db->query( "SHOW KEYS FROM `{$table}` WHERE Key_name = 'PRIMARY'" );
	if ( ! $result || $result->num_rows === 0 ) {
		if ( $result ) $result->free();
		return null;
	}
	$columns = [];
	while ( $row = $result->fetch_assoc() ) {
		$columns[ (int) $row['Seq_in_index'] ] = $row['Column_name'];
	}
	$result->free();
	ksort( $columns );
	return array_values( $columns );
}

/** Detect which columns contain binary data and need hex encoding. */
function sm_detect_binary_columns( $fields ) {
	$binary = [];
	foreach ( $fields as $i => $field ) {
		$is_binary = false;
		// BLOB types with BINARY flag (excludes TEXT which also reports as BLOB type).
		if ( in_array( $field->type, [ MYSQLI_TYPE_TINY_BLOB, MYSQLI_TYPE_MEDIUM_BLOB, MYSQLI_TYPE_LONG_BLOB, MYSQLI_TYPE_BLOB ], true ) ) {
			if ( $field->flags & MYSQLI_BINARY_FLAG ) {
				$is_binary = true;
			}
		}
		// BIT type.
		if ( $field->type === MYSQLI_TYPE_BIT ) {
			$is_binary = true;
		}
		// BINARY / VARBINARY (string type with binary charset 63).
		if ( in_array( $field->type, [ MYSQLI_TYPE_STRING, MYSQLI_TYPE_VAR_STRING ], true ) ) {
			if ( ( $field->flags & MYSQLI_BINARY_FLAG ) && $field->charsetnr === 63 ) {
				$is_binary = true;
			}
		}
		$binary[ $i ] = $is_binary;
	}
	return $binary;
}

/** Format a row's values for INSERT, using hex encoding for binary fields. */
function sm_format_row_values( $db, $row, $binary_cols ) {
	$values = [];
	foreach ( $row as $i => $value ) {
		if ( is_null( $value ) ) {
			$values[] = 'NULL';
		} elseif ( ! empty( $binary_cols[ $i ] ) ) {
			$values[] = strlen( $value ) === 0 ? "''" : '0x' . bin2hex( $value );
		} else {
			$values[] = "'" . $db->real_escape_string( $value ) . "'";
		}
	}
	return $values;
}

// ── GET ?action=list ────────────────────────────────────────────────

function sm_action_list() {
	$c      = $GLOBALS['sm_config'];
	$subdir = isset( $_GET['subdir'] ) ? $_GET['subdir'] : '';

	if ( $subdir ) {
		$subdir = str_replace( '\\', '/', $subdir );
		$subdir = trim( $subdir, '/' );
		if ( strpos( $subdir, '..' ) !== false ) {
			http_response_code( 400 );
			echo 'Invalid subdir.';
			exit;
		}
	}

	$basedir = $c['wp_content_dir'];
	if ( $subdir ) {
		$basedir .= '/' . $subdir;
	}

	if ( ! is_dir( $basedir ) ) {
		sm_json_response( [ 'subdir' => $subdir, 'dirs' => [], 'files' => 0 ] );
		return;
	}

	$dirs  = [];
	$files = 0;

	foreach ( scandir( $basedir ) as $item ) {
		if ( $item === '.' || $item === '..' ) {
			continue;
		}
		if ( is_dir( $basedir . '/' . $item ) ) {
			$dirs[] = $item;
		} else {
			$files++;
		}
	}

	sort( $dirs );

	sm_json_response( [ 'subdir' => $subdir, 'dirs' => $dirs, 'files' => $files ] );
}

// ── GET ?action=files ───────────────────────────────────────────────

function sm_action_files() {
	$c = $GLOBALS['sm_config'];

	@set_time_limit( 0 );
	@ignore_user_abort( false );

	while ( ob_get_level() ) {
		ob_end_clean();
	}

	$wp_content_dir = $c['wp_content_dir'];
	$subdir         = isset( $_GET['subdir'] ) ? $_GET['subdir'] : null;
	$basedir        = $wp_content_dir;

	if ( $subdir ) {
		$subdir = str_replace( '\\', '/', $subdir );
		$subdir = trim( $subdir, '/' );
		if ( strpos( $subdir, '..' ) !== false ) {
			http_response_code( 400 );
			echo 'Invalid subdir.';
			exit;
		}
		$basedir = $wp_content_dir . '/' . $subdir;
		if ( ! is_dir( $basedir ) ) {
			http_response_code( 404 );
			echo 'Subdir not found.';
			exit;
		}
	}

	$exclude_dirs = [ 'cache', 'upgrade', 'updraft', 'node_modules', 'ai1wm-backups', 'backups', 'backup', 'siteway-backups' ];

	$exclude_files = [
		'mu-plugins/siteway-migrator.php',
		'debug.log',
		'object-cache.php',
	];

	header( 'Content-Type: application/x-tar' );
	header( 'Content-Disposition: attachment; filename="wp-content.tar"' );
	header( 'X-Accel-Buffering: no' );
	header( 'Cache-Control: no-cache' );

	$iterator = new RecursiveIteratorIterator(
		new RecursiveDirectoryIterator( $basedir, RecursiveDirectoryIterator::SKIP_DOTS ),
		RecursiveIteratorIterator::SELF_FIRST
	);

	// maxdepth=0: only files in immediate directory (no recursion into subdirs)
	if ( isset( $_GET['maxdepth'] ) ) {
		$iterator->setMaxDepth( intval( $_GET['maxdepth'] ) );
	}

	foreach ( $iterator as $file ) {
		if ( connection_aborted() ) {
			exit;
		}

		$real_path = str_replace( '\\', '/', $file->getPathname() );
		$rel_path  = str_replace( '\\', '/', substr( $real_path, strlen( $wp_content_dir ) + 1 ) );

		// Check exclusions.
		$skip      = false;
		$top_level = explode( '/', $rel_path )[0];
		if ( in_array( $top_level, $exclude_dirs, true ) ) {
			$skip = true;
		}
		if ( ! $skip ) {
			foreach ( $exclude_files as $ex_file ) {
				if ( $rel_path === $ex_file || basename( $rel_path ) === $ex_file ) {
					$skip = true;
					break;
				}
			}
		}
		if ( $skip ) {
			continue;
		}

		if ( $file->isDir() ) {
			sm_tar_dir_entry( $rel_path );
		} elseif ( $file->isFile() && $file->isReadable() ) {
			$size = $file->getSize();
			if ( $size > 104857600 ) { // Skip >100MB.
				continue;
			}
			sm_tar_file_entry( $rel_path, $real_path, $size, $file->getMTime() );
		}
	}

	// End-of-archive marker.
	echo str_repeat( "\0", 1024 );
	flush();
	exit;
}

// ── GET ?action=core-files ───────────────────────────────────────────

function sm_action_core_files() {
	$abspath = $GLOBALS['sm_abspath'];

	@set_time_limit( 0 );
	@ignore_user_abort( false );

	while ( ob_get_level() ) {
		ob_end_clean();
	}

	// Files to exclude from root.
	$exclude = [
		'siteway-migrator.php',
		'.siteway-migrator-config.php',
		'.opcache-flush.php',
		'wp-config.php',
		'wp-config-sample.php',
	];

	header( 'Content-Type: application/x-tar' );
	header( 'Content-Disposition: attachment; filename="core-files.tar"' );
	header( 'X-Accel-Buffering: no' );
	header( 'Cache-Control: no-cache' );

	// 1. All root PHP files + .htaccess (catches non-standard files like hack additions).
	$root_entries = @scandir( $abspath );
	if ( $root_entries ) {
		foreach ( $root_entries as $filename ) {
			if ( in_array( $filename, $exclude, true ) ) {
				continue;
			}
			$full_path = $abspath . $filename;
			if ( ! is_file( $full_path ) ) {
				continue;
			}
			// Include .php files and .htaccess
			$ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
			if ( $ext !== 'php' && $filename !== '.htaccess' ) {
				continue;
			}
			sm_tar_file_entry( $filename, $full_path, filesize( $full_path ), filemtime( $full_path ) );
		}
	}

	// 2. wp-admin/ and wp-includes/ directories.
	$core_dirs = [ 'wp-admin', 'wp-includes' ];
	foreach ( $core_dirs as $dir ) {
		$dir_path = $abspath . $dir;
		if ( ! is_dir( $dir_path ) ) {
			continue;
		}

		$iterator = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $dir_path, RecursiveDirectoryIterator::SKIP_DOTS ),
			RecursiveIteratorIterator::SELF_FIRST
		);

		foreach ( $iterator as $file ) {
			if ( connection_aborted() ) {
				exit;
			}

			$real_path = str_replace( '\\', '/', $file->getPathname() );
			$rel_path  = str_replace( '\\', '/', substr( $real_path, strlen( $abspath ) ) );

			if ( $file->isDir() ) {
				sm_tar_dir_entry( $rel_path );
			} elseif ( $file->isFile() && $file->isReadable() ) {
				$size = $file->getSize();
				if ( $size > 52428800 ) { // Skip > 50MB.
					continue;
				}
				sm_tar_file_entry( $rel_path, $real_path, $size, $file->getMTime() );
			}
		}
	}

	// End-of-archive marker.
	echo str_repeat( "\0", 1024 );
	flush();
	exit;
}

// ── POST ?action=push ───────────────────────────────────────────────

function sm_action_push() {
	$c = $GLOBALS['sm_config'];
	@set_time_limit( 120 );

	$wp_content_dir = $c['wp_content_dir'];

	$skip_files = [
		'mu-plugins/demo-mode.php',
		'mu-plugins/siteway-autologin.php',
		'mu-plugins/siteway-migrator.php',
		'object-cache.php',
		'debug.log',
	];

	$input      = fopen( 'php://input', 'rb' );
	$timestamp  = gmdate( 'Y-m-d_His' );
	$backup_dir = $wp_content_dir . '/.push-backups/' . $timestamp;
	$pushed     = [];
	$skipped    = [];

	while ( ! feof( $input ) ) {
		$header_block = fread( $input, 512 );
		if ( strlen( $header_block ) < 512 ) {
			break;
		}

		if ( $header_block === str_repeat( "\0", 512 ) ) {
			break;
		}

		$name   = trim( substr( $header_block, 0, 100 ) );
		$size   = octdec( trim( substr( $header_block, 124, 12 ) ) );
		$type   = $header_block[156];
		$prefix = trim( substr( $header_block, 345, 155 ) );

		if ( $prefix ) {
			$name = $prefix . '/' . $name;
		}

		$data_blocks = ( $size > 0 ) ? (int) ceil( $size / 512 ) : 0;

		if ( $type === '5' || substr( $name, -1 ) === '/' ) {
			$dir_path = $wp_content_dir . '/' . $name;
			if ( sm_validate_push_path( $name ) ) {
				@mkdir( rtrim( $dir_path, '/' ), 0755, true );
			}
			continue;
		}

		// Read file content.
		$content   = '';
		$remaining = $data_blocks * 512;
		while ( $remaining > 0 ) {
			$chunk      = fread( $input, min( 8192, $remaining ) );
			$content   .= $chunk;
			$remaining -= strlen( $chunk );
			if ( strlen( $chunk ) === 0 ) {
				break;
			}
		}
		$content = substr( $content, 0, $size );

		if ( ! sm_validate_push_path( $name ) ) {
			$skipped[] = $name . ' (invalid path)';
			continue;
		}

		$rel_from_wpcontent = $name;
		if ( strpos( $name, 'wp-content/' ) === 0 ) {
			$rel_from_wpcontent = substr( $name, strlen( 'wp-content/' ) );
		}

		$skip = false;
		foreach ( $skip_files as $sf ) {
			if ( $rel_from_wpcontent === $sf ) {
				$skip = true;
				break;
			}
		}
		if ( $skip ) {
			$skipped[] = $name . ' (demo-only)';
			continue;
		}

		$target      = $wp_content_dir . '/' . $rel_from_wpcontent;
		$backup_path = $backup_dir . '/' . $rel_from_wpcontent;
		@mkdir( dirname( $backup_path ), 0755, true );

		if ( file_exists( $target ) ) {
			copy( $target, $backup_path );
		} else {
			file_put_contents( $backup_path . '.NEW_FILE_MARKER', '' );
		}

		@mkdir( dirname( $target ), 0755, true );
		file_put_contents( $target, $content );

		$pushed[] = $rel_from_wpcontent;
	}

	fclose( $input );

	sm_json_response( [
		'pushed'  => $pushed,
		'skipped' => $skipped,
		'backup'  => '.push-backups/' . $timestamp,
		'count'   => count( $pushed ),
	] );
}

// ── GET ?action=push-backups ────────────────────────────────────────

function sm_action_push_backups() {
	$backups_root = $GLOBALS['sm_config']['wp_content_dir'] . '/.push-backups';

	if ( ! is_dir( $backups_root ) ) {
		sm_json_response( [ 'backups' => [] ] );
	}

	$dirs   = glob( $backups_root . '/*', GLOB_ONLYDIR );
	$result = [];

	if ( empty( $dirs ) ) {
		sm_json_response( [ 'backups' => [] ] );
	}

	sort( $dirs );

	foreach ( $dirs as $dir ) {
		$ts    = basename( $dir );
		$files = [];

		$iterator = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS )
		);
		foreach ( $iterator as $file ) {
			if ( $file->isFile() ) {
				$rel = str_replace( '\\', '/', substr( $file->getPathname(), strlen( $dir ) + 1 ) );
				if ( substr( $rel, -16 ) === '.NEW_FILE_MARKER' ) {
					$files[] = substr( $rel, 0, -16 ) . ' (new)';
				} else {
					$files[] = $rel;
				}
			}
		}

		$result[] = [
			'timestamp' => $ts,
			'files'     => $files,
			'count'     => count( $files ),
		];
	}

	sm_json_response( [ 'backups' => $result ] );
}

// ── POST ?action=undo-push ──────────────────────────────────────────

function sm_action_undo_push() {
	$wp_content_dir = $GLOBALS['sm_config']['wp_content_dir'];
	$backups_root   = $wp_content_dir . '/.push-backups';

	if ( ! is_dir( $backups_root ) ) {
		sm_json_error( 'No push backups found.', 404 );
	}

	$body      = json_decode( file_get_contents( 'php://input' ), true );
	$timestamp = isset( $body['timestamp'] ) ? $body['timestamp'] : null;

	if ( $timestamp ) {
		if ( strpos( $timestamp, '..' ) !== false || strpos( $timestamp, '/' ) !== false ) {
			sm_json_error( 'Invalid timestamp.', 400 );
		}
		$backup_dir = $backups_root . '/' . $timestamp;
		if ( ! is_dir( $backup_dir ) ) {
			sm_json_error( "Backup '{$timestamp}' not found.", 404 );
		}
	} else {
		$dirs = glob( $backups_root . '/*', GLOB_ONLYDIR );
		if ( empty( $dirs ) ) {
			sm_json_error( 'No push backups found.', 404 );
		}
		sort( $dirs );
		$backup_dir = end( $dirs );
		$timestamp  = basename( $backup_dir );
	}

	$restored = [];
	$deleted  = [];
	$errors   = [];

	$iterator = new RecursiveIteratorIterator(
		new RecursiveDirectoryIterator( $backup_dir, RecursiveDirectoryIterator::SKIP_DOTS ),
		RecursiveIteratorIterator::SELF_FIRST
	);

	foreach ( $iterator as $file ) {
		if ( ! $file->isFile() ) {
			continue;
		}

		$rel_path = str_replace( '\\', '/', substr( $file->getPathname(), strlen( $backup_dir ) + 1 ) );

		if ( strpos( $rel_path, '..' ) !== false ) {
			$errors[] = $rel_path . ' (path traversal)';
			continue;
		}

		if ( substr( $rel_path, -16 ) === '.NEW_FILE_MARKER' ) {
			$original_rel = substr( $rel_path, 0, -16 );
			$target       = $wp_content_dir . '/' . $original_rel;
			if ( file_exists( $target ) ) {
				if ( unlink( $target ) ) {
					$deleted[] = $original_rel;
				} else {
					$errors[] = $original_rel . ' (delete failed)';
				}
			}
			continue;
		}

		$target = $wp_content_dir . '/' . $rel_path;
		@mkdir( dirname( $target ), 0755, true );
		if ( copy( $file->getPathname(), $target ) ) {
			$restored[] = $rel_path;
		} else {
			$errors[] = $rel_path . ' (copy failed)';
		}
	}

	sm_json_response( [
		'restored'    => $restored,
		'deleted'     => $deleted,
		'errors'      => $errors,
		'backup_used' => $timestamp,
		'count'       => count( $restored ) + count( $deleted ),
	] );
}

// ── POST ?action=cleanup ────────────────────────────────────────────

function sm_action_cleanup() {
	$backups_root = $GLOBALS['sm_config']['wp_content_dir'] . '/.push-backups';

	if ( ! is_dir( $backups_root ) ) {
		sm_json_response( [
			'success'      => true,
			'deleted_dirs' => [],
			'total_files'  => 0,
			'message'      => 'No push backups to clean up.',
		] );
	}

	$dirs        = glob( $backups_root . '/*', GLOB_ONLYDIR );
	$deleted     = [];
	$total_files = 0;

	foreach ( $dirs as $dir ) {
		$iterator = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS )
		);
		$count = 0;
		foreach ( $iterator as $file ) {
			if ( $file->isFile() ) {
				$count++;
			}
		}
		$total_files += $count;
		$deleted[]    = basename( $dir );
		sm_rmdir_recursive( $dir );
	}

	@rmdir( $backups_root );

	sm_json_response( [
		'success'      => true,
		'deleted_dirs' => $deleted,
		'total_files'  => $total_files,
		'message'      => count( $deleted ) . ' backup(s) removed (' . $total_files . ' files).',
	] );
}

// ── POST ?action=reinstall-core ─────────────────────────────────────

function sm_action_reinstall_core() {
	$abspath = $GLOBALS['sm_abspath'];

	@set_time_limit( 300 );

	// 1. Read current WP version.
	$version_file = $abspath . 'wp-includes/version.php';
	$wp_version   = null;

	if ( file_exists( $version_file ) ) {
		$content = file_get_contents( $version_file );
		if ( preg_match( "/\\\$wp_version\s*=\s*['\"](.+?)['\"]/", $content, $m ) ) {
			$wp_version = $m[1];
		}
	}

	if ( ! $wp_version ) {
		sm_json_error( 'Cannot determine WordPress version from wp-includes/version.php', 500 );
	}

	// 2. Download WordPress zip from wordpress.org.
	$zip_url = "https://wordpress.org/wordpress-{$wp_version}.zip";
	$tmp_zip = sys_get_temp_dir() . '/wordpress-' . $wp_version . '-' . uniqid() . '.zip';

	$ch = curl_init( $zip_url );
	$fp = fopen( $tmp_zip, 'wb' );
	curl_setopt( $ch, CURLOPT_FILE, $fp );
	curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
	curl_setopt( $ch, CURLOPT_TIMEOUT, 120 );
	curl_setopt( $ch, CURLOPT_FAILONERROR, true );
	$success   = curl_exec( $ch );
	$http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
	$curl_err  = curl_error( $ch );
	curl_close( $ch );
	fclose( $fp );

	if ( ! $success || $http_code !== 200 ) {
		@unlink( $tmp_zip );
		sm_json_error( "Failed to download WordPress {$wp_version}: {$curl_err}", 500 );
	}

	// 3. Check for ZipArchive.
	if ( ! class_exists( 'ZipArchive' ) ) {
		@unlink( $tmp_zip );
		sm_json_error( 'ZipArchive PHP extension not available.', 500 );
	}

	$zip = new ZipArchive();
	if ( $zip->open( $tmp_zip ) !== true ) {
		@unlink( $tmp_zip );
		sm_json_error( 'Failed to open downloaded WordPress zip.', 500 );
	}

	// 4. Create backup directory.
	$wp_content_dir = $GLOBALS['sm_config']['wp_content_dir'];
	$timestamp      = gmdate( 'Y-m-d_His' );
	$backup_dir     = $wp_content_dir . '/.push-backups/core-repair-' . $timestamp;

	if ( ! @mkdir( $backup_dir, 0755, true ) ) {
		$zip->close();
		@unlink( $tmp_zip );
		sm_json_error( 'Failed to create backup directory.', 500 );
	}

	// 5. Extract core files from zip.
	// WordPress zip structure: wordpress/wp-admin/..., wordpress/wp-includes/..., wordpress/index.php, etc.
	// Skip: wp-content/, wp-config-sample.php
	$restored  = 0;
	$backed_up = 0;
	$errors    = [];

	for ( $i = 0; $i < $zip->numFiles; $i++ ) {
		$entry = $zip->getNameIndex( $i );

		if ( strpos( $entry, 'wordpress/' ) !== 0 ) {
			continue;
		}

		$rel_path = substr( $entry, strlen( 'wordpress/' ) );
		if ( empty( $rel_path ) ) {
			continue;
		}

		// Skip wp-content/ and wp-config-sample.php.
		if ( strpos( $rel_path, 'wp-content/' ) === 0 || $rel_path === 'wp-config-sample.php' ) {
			continue;
		}

		// Directory entry.
		if ( substr( $entry, -1 ) === '/' ) {
			$dir_path = $abspath . $rel_path;
			if ( ! is_dir( $dir_path ) ) {
				@mkdir( $dir_path, 0755, true );
			}
			continue;
		}

		$target = $abspath . $rel_path;

		// Backup existing file.
		if ( file_exists( $target ) ) {
			$backup_target = $backup_dir . '/' . $rel_path;
			@mkdir( dirname( $backup_target ), 0755, true );
			if ( ! @copy( $target, $backup_target ) ) {
				$errors[] = $rel_path . ' (backup failed)';
				continue;
			}
			$backed_up++;
		}

		// Extract from zip.
		$content = $zip->getFromIndex( $i );
		if ( $content === false ) {
			$errors[] = $rel_path . ' (extract failed)';
			continue;
		}

		@mkdir( dirname( $target ), 0755, true );
		if ( file_put_contents( $target, $content ) !== false ) {
			$restored++;
		} else {
			$errors[] = $rel_path . ' (write failed)';
		}
	}

	$zip->close();
	@unlink( $tmp_zip );

	sm_json_response( [
		'success'     => true,
		'wp_version'  => $wp_version,
		'restored'    => $restored,
		'backed_up'   => $backed_up,
		'errors'      => $errors,
		'backup_path' => '.push-backups/core-repair-' . $timestamp,
		'message'     => "WordPress {$wp_version} core restored. {$restored} files replaced, {$backed_up} backed up.",
	] );
}

// ── POST ?action=uninstall ──────────────────────────────────────────

function sm_action_uninstall() {
	$abspath        = $GLOBALS['sm_abspath'];
	$wp_content_dir = $GLOBALS['sm_config']['wp_content_dir'];

	$deleted = [];

	// Invalidate OPcache for all migrator files before deleting.
	$files_to_delete = [];

	$self = $abspath . 'siteway-migrator.php';
	if ( file_exists( $self ) ) {
		$files_to_delete['siteway-migrator.php'] = $self;
	}

	$config = $abspath . '.siteway-migrator-config.php';
	if ( file_exists( $config ) ) {
		$files_to_delete['.siteway-migrator-config.php'] = $config;
	}

	$mu_plugin = $wp_content_dir . '/mu-plugins/siteway-migrator.php';
	if ( file_exists( $mu_plugin ) ) {
		$files_to_delete['mu-plugins/siteway-migrator.php'] = $mu_plugin;
	}

	// Check for regular plugin directory.
	$plugin_dir = $wp_content_dir . '/plugins/siteway-migrator';
	$plugin_file = $plugin_dir . '/siteway-migrator.php';
	if ( is_dir( $plugin_dir ) && file_exists( $plugin_file ) ) {
		$files_to_delete['plugins/siteway-migrator/siteway-migrator.php'] = $plugin_file;
	}

	// Invalidate OPcache for all files.
	if ( function_exists( 'opcache_invalidate' ) ) {
		foreach ( $files_to_delete as $path ) {
			@opcache_invalidate( $path, true );
		}
	}

	// Delete files.
	foreach ( $files_to_delete as $label => $path ) {
		if ( @unlink( $path ) ) {
			$deleted[] = $label;
		} else {
			$deleted[] = $label . ' (FAILED)';
		}
	}

	// Deactivate regular plugin in DB (best-effort, non-critical).
	if ( is_dir( $plugin_dir ) ) {
		try {
			sm_deactivate_plugin( 'siteway-migrator/siteway-migrator.php' );
		} catch ( \Throwable $e ) {
			// Ignore — plugin may stay in active_plugins but files are gone.
		}
		sm_rmdir_recursive( $plugin_dir );
		$deleted[] = 'plugins/siteway-migrator/';
	}

	sm_json_response( [
		'success' => true,
		'deleted' => $deleted,
		'message' => 'Migrator uninstalled. ' . count( $deleted ) . ' file(s) removed.',
	] );
}

// ── Tar helpers ─────────────────────────────────────────────────────

function sm_tar_dir_entry( $name ) {
	$name   = rtrim( $name, '/' ) . '/';
	sm_tar_longlink_if_needed( $name );
	$header = sm_tar_header( $name, 0, time(), '5' );
	echo $header;
	flush();
}

function sm_tar_file_entry( $name, $real_path, $size, $mtime ) {
	// Re-check size and readability right before writing (file may have
	// changed since the directory listing, e.g. Imagify optimizing images).
	if ( ! is_readable( $real_path ) ) {
		return;
	}
	$actual_size = @filesize( $real_path );
	if ( $actual_size === false || $actual_size < 0 ) {
		return;
	}

	sm_tar_longlink_if_needed( $name );
	$header = sm_tar_header( $name, $actual_size, $mtime, '0' );
	echo $header;

	$fh = @fopen( $real_path, 'rb' );
	if ( $fh ) {
		$remaining = $actual_size;
		while ( $remaining > 0 && ! feof( $fh ) ) {
			$to_read = min( 8192, $remaining );
			$chunk   = fread( $fh, $to_read );
			if ( $chunk === false || strlen( $chunk ) === 0 ) {
				break;
			}
			echo $chunk;
			$remaining -= strlen( $chunk );
		}
		fclose( $fh );

		// If file was shorter than expected, pad content with zeros.
		if ( $remaining > 0 ) {
			echo str_repeat( "\0", $remaining );
		}
	} else {
		// fopen failed after header was written — fill declared size with zeros.
		if ( $actual_size > 0 ) {
			echo str_repeat( "\0", $actual_size );
		}
	}

	// Align to 512-byte boundary (based on header-declared size).
	$padding = 512 - ( $actual_size % 512 );
	if ( $padding < 512 ) {
		echo str_repeat( "\0", $padding );
	}

	flush();
}

/**
 * Emit a GNU @@LongLink entry if the path exceeds ustar limits.
 * Must be called BEFORE sm_tar_header() for the same entry.
 */
function sm_tar_longlink_if_needed( $name ) {
	// ustar can handle: prefix (155) + '/' + name (100) = 256 chars.
	// But the filename part (after last /) must be <= 100 chars.
	// If the path doesn't fit, emit a @LongLink (type 'L') header.
	$needs_longlink = false;

	if ( strlen( $name ) > 100 ) {
		$slash = strrpos( substr( $name, 0, 155 ), '/' );
		if ( $slash === false ) {
			// No slash in first 155 chars — filename alone > 100.
			$needs_longlink = true;
		} else {
			$file_part = substr( $name, $slash + 1 );
			if ( strlen( $file_part ) > 100 ) {
				$needs_longlink = true;
			}
		}
	}

	if ( ! $needs_longlink ) {
		return;
	}

	// Write @LongLink header: stores the full path as data.
	$name_data  = $name . "\0"; // null-terminated
	$name_len   = strlen( $name_data );
	$data_blocks = (int) ceil( $name_len / 512 );

	$lh  = '';
	$lh .= str_pad( '././@LongLink', 100, "\0" );                        // name
	$lh .= str_pad( decoct( 0 ), 7, '0', STR_PAD_LEFT ) . "\0";          // mode
	$lh .= str_pad( decoct( 0 ), 7, '0', STR_PAD_LEFT ) . "\0";          // uid
	$lh .= str_pad( decoct( 0 ), 7, '0', STR_PAD_LEFT ) . "\0";          // gid
	$lh .= str_pad( decoct( $name_len ), 11, '0', STR_PAD_LEFT ) . "\0"; // size
	$lh .= str_pad( decoct( 0 ), 11, '0', STR_PAD_LEFT ) . "\0";         // mtime
	$lh .= '        ';                                                    // checksum placeholder
	$lh .= 'L';                                                           // typeflag = LongLink
	$lh .= str_repeat( "\0", 100 );                                       // linkname
	$lh .= "ustar\0";                                                     // magic
	$lh .= "00";                                                          // version
	$lh .= str_pad( 'root', 32, "\0" );                                   // uname
	$lh .= str_pad( 'root', 32, "\0" );                                   // gname
	$lh .= str_repeat( "\0", 8 );                                         // devmajor
	$lh .= str_repeat( "\0", 8 );                                         // devminor
	$lh .= str_repeat( "\0", 155 );                                       // prefix
	$lh .= str_repeat( "\0", 12 );                                        // padding

	// Compute checksum.
	$checksum = 0;
	for ( $i = 0; $i < 512; $i++ ) {
		$checksum += ord( $lh[ $i ] );
	}
	$checksum = str_pad( decoct( $checksum ), 6, '0', STR_PAD_LEFT ) . "\0 ";
	$lh       = substr_replace( $lh, $checksum, 148, 8 );

	echo $lh;

	// Write the full name as data (padded to 512-byte boundary).
	echo $name_data;
	$pad = $data_blocks * 512 - $name_len;
	if ( $pad > 0 ) {
		echo str_repeat( "\0", $pad );
	}
}

function sm_tar_header( $name, $size, $mtime, $type ) {
	$prefix = '';
	if ( strlen( $name ) > 100 ) {
		$slash = strrpos( substr( $name, 0, 155 ), '/' );
		if ( $slash !== false ) {
			$prefix = substr( $name, 0, $slash );
			$name   = substr( $name, $slash + 1 );
		}
	}

	// Truncate to fit ustar fields (LongLink header handles the full name).
	if ( strlen( $name ) > 100 ) {
		$name = substr( $name, 0, 100 );
	}
	if ( strlen( $prefix ) > 155 ) {
		$prefix = substr( $prefix, 0, 155 );
	}

	$header  = '';
	$header .= str_pad( $name, 100, "\0" );
	$header .= str_pad( decoct( 0644 ), 7, '0', STR_PAD_LEFT ) . "\0";
	$header .= str_pad( decoct( 0 ), 7, '0', STR_PAD_LEFT ) . "\0";
	$header .= str_pad( decoct( 0 ), 7, '0', STR_PAD_LEFT ) . "\0";
	$header .= str_pad( decoct( $size ), 11, '0', STR_PAD_LEFT ) . "\0";
	$header .= str_pad( decoct( $mtime ), 11, '0', STR_PAD_LEFT ) . "\0";
	$header .= '        ';
	$header .= $type;
	$header .= str_repeat( "\0", 100 );
	$header .= "ustar\0";
	$header .= "00";
	$header .= str_pad( 'www-data', 32, "\0" );
	$header .= str_pad( 'www-data', 32, "\0" );
	$header .= str_repeat( "\0", 8 );
	$header .= str_repeat( "\0", 8 );
	$header .= str_pad( $prefix, 155, "\0" );
	$header .= str_repeat( "\0", 12 );

	$checksum = 0;
	for ( $i = 0; $i < 512; $i++ ) {
		$checksum += ord( $header[ $i ] );
	}
	$checksum = str_pad( decoct( $checksum ), 6, '0', STR_PAD_LEFT ) . "\0 ";
	$header   = substr_replace( $header, $checksum, 148, 8 );

	return $header;
}

// ── Utility functions ───────────────────────────────────────────────

function sm_get_option( $db, $prefix, $option_name ) {
	$result = $db->query( "SELECT option_value FROM `{$prefix}options` WHERE option_name = '" . $db->real_escape_string( $option_name ) . "' LIMIT 1" );
	if ( $result && $row = $result->fetch_assoc() ) {
		$result->free();
		return $row['option_value'];
	}
	return null;
}

function sm_validate_push_path( $path ) {
	if ( strpos( $path, '..' ) !== false ) {
		return false;
	}
	if ( strpos( $path, 'wp-content/' ) === 0 ) {
		return true;
	}
	$allowed_prefixes = [ 'themes/', 'plugins/', 'mu-plugins/', 'languages/' ];
	foreach ( $allowed_prefixes as $ap ) {
		if ( strpos( $path, $ap ) === 0 ) {
			return true;
		}
	}
	return false;
}

function sm_dir_size( $dir, $exclude_dirs = [] ) {
	$size = 0;
	try {
		$iterator = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
			RecursiveIteratorIterator::SELF_FIRST
		);
		foreach ( $iterator as $file ) {
			if ( ! empty( $exclude_dirs ) ) {
				$rel = str_replace( '\\', '/', substr( $file->getPathname(), strlen( $dir ) + 1 ) );
				$top = explode( '/', $rel )[0];
				if ( in_array( $top, $exclude_dirs, true ) ) {
					continue;
				}
			}
			if ( $file->isFile() ) {
				$size += $file->getSize();
			}
		}
	} catch ( Exception $e ) {
		// Permission denied — return what we have.
	}
	return $size;
}

function sm_rmdir_recursive( $dir ) {
	$iterator = new RecursiveIteratorIterator(
		new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
		RecursiveIteratorIterator::CHILD_FIRST
	);
	foreach ( $iterator as $file ) {
		if ( $file->isDir() ) {
			@rmdir( $file->getPathname() );
		} else {
			@unlink( $file->getPathname() );
		}
	}
	@rmdir( $dir );
}
