<?php
/**
 * Plugin Name: Siteway Migrator
 * Description: Streaming WordPress export for zero-disk-usage site migration. Temporary — remove after use.
 * Version: 1.0.0
 * Author: Siteway Oy
 * License: GPL-2.0-or-later
 *
 * Endpoints (auth: Bearer token):
 *   GET  /wp-json/siteway-migrate/v1/info   — site metadata
 *   GET  /wp-json/siteway-migrate/v1/db     — streaming SQL dump
 *   GET  /wp-json/siteway-migrate/v1/files  — streaming tar of wp-content
 */

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

add_action( 'rest_api_init', 'siteway_migrator_register_routes' );

function siteway_migrator_register_routes() {
	$namespace = 'siteway-migrate/v1';

	register_rest_route( $namespace, '/info', [
		'methods'             => 'GET',
		'callback'            => 'siteway_migrator_info',
		'permission_callback' => 'siteway_migrator_check_auth',
	] );

	register_rest_route( $namespace, '/db', [
		'methods'             => 'GET',
		'callback'            => 'siteway_migrator_db',
		'permission_callback' => 'siteway_migrator_check_auth',
	] );

	register_rest_route( $namespace, '/files', [
		'methods'             => 'GET',
		'callback'            => 'siteway_migrator_files',
		'permission_callback' => 'siteway_migrator_check_auth',
	] );
}

/**
 * Authenticate via Bearer token.
 */
function siteway_migrator_check_auth( WP_REST_Request $request ) {
	$header = $request->get_header( 'Authorization' );
	if ( ! $header || stripos( $header, 'Bearer ' ) !== 0 ) {
		return new WP_Error( 'rest_forbidden', 'Missing Bearer token.', [ 'status' => 401 ] );
	}

	$token = trim( substr( $header, 7 ) );
	$key   = defined( 'SITEWAY_MIGRATOR_KEY' ) ? SITEWAY_MIGRATOR_KEY : get_option( 'siteway_migrator_secret_key', '' );

	if ( empty( $key ) || ! hash_equals( $key, $token ) ) {
		return new WP_Error( 'rest_forbidden', 'Invalid token.', [ 'status' => 403 ] );
	}

	return true;
}

/**
 * GET /info — Return site metadata as JSON.
 */
function siteway_migrator_info() {
	global $wpdb;

	// Database size.
	$db_size = 0;
	$tables  = $wpdb->get_results(
		$wpdb->prepare(
			"SELECT table_name, data_length + index_length AS size
			 FROM information_schema.TABLES
			 WHERE table_schema = %s",
			DB_NAME
		)
	);
	foreach ( $tables as $table ) {
		$db_size += (int) $table->size;
	}

	// wp-content size.
	$wp_content_size = siteway_migrator_dir_size( WP_CONTENT_DIR );

	// Active plugins.
	$active_plugins = get_option( 'active_plugins', [] );

	// Disk free.
	$disk_free = function_exists( 'disk_free_space' ) ? @disk_free_space( ABSPATH ) : null;

	return [
		'site_url'               => get_option( 'siteurl' ),
		'home_url'               => get_option( 'home' ),
		'wp_version'             => get_bloginfo( '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'           => $wpdb->prefix,
		'is_multisite'           => is_multisite(),
	];
}

/**
 * GET /db — Stream SQL dump directly.
 *
 * Uses $wpdb queries, not mysqldump CLI. Works on any host.
 * Streams output — zero temp files on disk.
 */
function siteway_migrator_db() {
	global $wpdb;

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

	// Flush all output buffers.
	while ( ob_get_level() ) {
		ob_end_clean();
	}

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

	echo "-- Siteway Migrator SQL Dump\n";
	echo "-- Database: " . DB_NAME . "\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 = $wpdb->get_col( 'SHOW TABLES' );

	foreach ( $tables as $table ) {
		// Table structure.
		$create = $wpdb->get_row( "SHOW CREATE TABLE `{$table}`", ARRAY_N );
		echo "DROP TABLE IF EXISTS `{$table}`;\n";
		echo $create[1] . ";\n\n";

		// Table data in batches.
		$batch_size = 1000;
		$offset     = 0;

		while ( true ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$rows = $wpdb->get_results(
				"SELECT * FROM `{$table}` LIMIT {$batch_size} OFFSET {$offset}",
				ARRAY_A
			);

			if ( empty( $rows ) ) {
				break;
			}

			foreach ( $rows as $row ) {
				$values = [];
				foreach ( $row as $value ) {
					if ( is_null( $value ) ) {
						$values[] = 'NULL';
					} else {
						$values[] = "'" . $wpdb->_real_escape( $value ) . "'";
					}
				}
				$columns = array_map( function( $col ) {
					return '`' . $col . '`';
				}, array_keys( $row ) );

				echo "INSERT INTO `{$table}` (" . implode( ', ', $columns ) . ") VALUES (" . implode( ', ', $values ) . ");\n";
			}

			$offset += $batch_size;
			flush();

			// Check if client disconnected.
			if ( connection_aborted() ) {
				exit;
			}
		}

		echo "\n";
	}

	echo "SET foreign_key_checks = 1;\n";
	exit;
}

/**
 * GET /files — Stream wp-content as tar.
 *
 * Accepts ?subdir=uploads/2024 for chunked pulls (PHP timeout mitigation).
 * Generates tar on-the-fly — zero disk usage.
 */
function siteway_migrator_files( WP_REST_Request $request ) {
	@set_time_limit( 0 );
	@ignore_user_abort( false );

	// Flush all output buffers.
	while ( ob_get_level() ) {
		ob_end_clean();
	}

	$subdir  = $request->get_param( 'subdir' );
	$basedir = WP_CONTENT_DIR;

	if ( $subdir ) {
		// Sanitize: no directory traversal.
		$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;
		}
	}

	// Directories to exclude.
	$exclude = [
		'cache',
		'upgrade',
		'updraft',
		'node_modules',
		'ai1wm-backups',
		'backups',
		'backup',
		'siteway-backups',
		'mu-plugins/siteway-migrator.php', // Don't export ourselves.
	];

	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
	);

	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;
		foreach ( $exclude as $ex ) {
			if ( strpos( $rel_path, $ex ) === 0 || strpos( $rel_path, '/' . $ex ) !== false ) {
				$skip = true;
				break;
			}
		}
		// Skip debug.log.
		if ( basename( $rel_path ) === 'debug.log' ) {
			$skip = true;
		}
		if ( $skip ) {
			continue;
		}

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

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

/**
 * Write a directory entry to the tar stream.
 */
function siteway_migrator_tar_dir_entry( $name ) {
	$name = rtrim( $name, '/' ) . '/';
	$header = siteway_migrator_tar_header( $name, 0, time(), '5' ); // '5' = directory.
	echo $header;
	flush();
}

/**
 * Write a file entry to the tar stream.
 */
function siteway_migrator_tar_file_entry( $name, $real_path, $size, $mtime ) {
	$header = siteway_migrator_tar_header( $name, $size, $mtime, '0' ); // '0' = regular file.
	echo $header;

	// Stream file contents in 8KB chunks.
	$fh = fopen( $real_path, 'rb' );
	if ( $fh ) {
		$bytes_written = 0;
		while ( ! feof( $fh ) ) {
			$chunk = fread( $fh, 8192 );
			echo $chunk;
			$bytes_written += strlen( $chunk );
		}
		fclose( $fh );

		// Pad to 512-byte boundary.
		$padding = 512 - ( $bytes_written % 512 );
		if ( $padding < 512 ) {
			echo str_repeat( "\0", $padding );
		}
	}

	flush();
}

/**
 * Generate a POSIX tar header (512 bytes).
 */
function siteway_migrator_tar_header( $name, $size, $mtime, $type ) {
	// Truncate name to 100 chars (tar limitation for basic headers).
	// For longer paths, use prefix field.
	$prefix = '';
	if ( strlen( $name ) > 100 ) {
		$slash = strrpos( substr( $name, 0, 155 ), '/' );
		if ( $slash !== false ) {
			$prefix = substr( $name, 0, $slash );
			$name   = substr( $name, $slash + 1 );
		}
	}

	$header = '';
	$header .= str_pad( $name, 100, "\0" );            // File name (100 bytes).
	$header .= str_pad( decoct( 0644 ), 7, '0', STR_PAD_LEFT ) . "\0"; // Mode (8 bytes).
	$header .= str_pad( decoct( 0 ), 7, '0', STR_PAD_LEFT ) . "\0";    // UID (8 bytes).
	$header .= str_pad( decoct( 0 ), 7, '0', STR_PAD_LEFT ) . "\0";    // GID (8 bytes).
	$header .= str_pad( decoct( $size ), 11, '0', STR_PAD_LEFT ) . "\0"; // Size (12 bytes).
	$header .= str_pad( decoct( $mtime ), 11, '0', STR_PAD_LEFT ) . "\0"; // Mtime (12 bytes).
	$header .= '        ';                              // Checksum placeholder (8 bytes).
	$header .= $type;                                   // Type flag (1 byte).
	$header .= str_repeat( "\0", 100 );                 // Linkname (100 bytes).
	$header .= "ustar\0";                               // Magic (6 bytes).
	$header .= "00";                                    // Version (2 bytes).
	$header .= str_pad( 'www-data', 32, "\0" );         // Uname (32 bytes).
	$header .= str_pad( 'www-data', 32, "\0" );         // Gname (32 bytes).
	$header .= str_repeat( "\0", 8 );                   // Devmajor (8 bytes).
	$header .= str_repeat( "\0", 8 );                   // Devminor (8 bytes).
	$header .= str_pad( $prefix, 155, "\0" );            // Prefix (155 bytes).
	$header .= str_repeat( "\0", 12 );                  // Padding (12 bytes). Total = 512.

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

	// Insert checksum at offset 148.
	$header = substr_replace( $header, $checksum, 148, 8 );

	return $header;
}

/**
 * Calculate directory size recursively.
 */
function siteway_migrator_dir_size( $dir ) {
	$size = 0;
	try {
		$iterator = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS )
		);
		foreach ( $iterator as $file ) {
			if ( $file->isFile() ) {
				$size += $file->getSize();
			}
		}
	} catch ( Exception $e ) {
		// Permission denied or similar — return what we have.
	}
	return $size;
}
