<?php
/**
 * ANCP Core - prefix rename migration (apft_ -> ancp_).
 *
 * Executes the spec-00 Section 9 decision: rename the 12 core tables from the
 * legacy apft_ prefix to ancp_, once, guarded and abort-safe. The single seam
 * for the change is Table_Map; this class flips the stored prefix only
 * after the physical rename has succeeded and verified.
 *
 * Safety model:
 * - Renames run one fully prepared statement per table pair. An interruption
 *   leaves a mixed state that the next run detects and completes safely via
 *   the table-by-table recovery path.
 * - Idempotent: re-running after success is a no-op.
 * - Recovery: if the process died in the narrow window between a successful
 *   rename and writing the prefix flag, the next run detects that the tables
 *   are already ancp_ and just sets the flag. A genuinely mixed state (some
 *   legacy, some target) is completed table-by-table, aborting on any collision.
 * - charts is never touched (Trees-owned, stays apft_charts).
 * - Multisite: names resolve through $wpdb->prefix, so each site migrates with
 *   its own tables when its upgrade path runs.
 *
 * @package AncestorPress\Core
 * @since   0.2.0
 */

namespace AncestorPress\Core;

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

/**
 * Core prefix migrator.
 *
 * @since 0.2.0
 */
class Migrator {

	/**
	 * Run the prefix migration if needed. Idempotent and abort-safe.
	 *
	 * @since  0.2.0
	 * @return array {
	 *     @type string $status  'migrated'|'already'|'fresh'|'recovered'|'error'.
	 *     @type int    $renamed Number of tables renamed this run.
	 *     @type string $error   Present when status is 'error'.
	 * }
	 */
	public static function maybe_migrate() {
		// Only tables that shipped as apft_ are subject to the rename; tables
		// born on ancp_ (e.g. names) are excluded.
		$keys  = Table_Map::renamable_keys();
		$total = count( $keys );

		$legacy = 0;
		$target = 0;
		foreach ( $keys as $key ) {
			if ( self::table_exists( Table_Map::name_with_prefix( $key, Table_Map::LEGACY_PREFIX ) ) ) {
				++$legacy;
			}
			if ( self::table_exists( Table_Map::name_with_prefix( $key, Table_Map::TARGET_PREFIX ) ) ) {
				++$target;
			}
		}

		// Already fully on the target prefix physically. Reconcile the flag in
		// case a prior run died after renaming but before writing it.
		if ( $total === $target && 0 === $legacy ) {
			if ( Table_Map::TARGET_PREFIX !== Table_Map::active_prefix() ) {
				Table_Map::set_active_prefix( Table_Map::TARGET_PREFIX );
			}
			return self::result( 'already', 0 );
		}

		// No core tables at all: a fresh install. Be born on the target prefix
		// so the schema installer creates ancp_ tables directly.
		if ( 0 === $legacy && 0 === $target ) {
			Table_Map::set_active_prefix( Table_Map::TARGET_PREFIX );
			return self::result( 'fresh', 0 );
		}

		// Clean legacy install ready to migrate: all legacy present, none target.
		if ( $total === $legacy && 0 === $target ) {
			return self::rename_all( $keys );
		}

		// Anything else is a mixed state (an interrupted rename that was not
		// atomic, or hand-edited tables): complete it table-by-table.
		return self::recover_partial( $keys );
	}

	/**
	 * The clean-migration path: one atomic multi-table rename, then verify.
	 *
	 * @since  0.2.0
	 * @param  string[] $keys Core table keys.
	 * @return array
	 */
	private static function rename_all( $keys ) {
		global $wpdb;

		$pairs = array();
		foreach ( $keys as $key ) {
			$from = Table_Map::name_with_prefix( $key, Table_Map::LEGACY_PREFIX );
			$to   = Table_Map::name_with_prefix( $key, Table_Map::TARGET_PREFIX );

			// Pre-flight: source must exist, target must not.
			if ( ! self::table_exists( $from ) ) {
				return self::error( "pre-flight: missing source table {$from}" );
			}
			if ( self::table_exists( $to ) ) {
				return self::error( "pre-flight: target table already exists {$to}" );
			}
			$pairs[] = array( $from, $to );
		}

		// One fully prepared statement per pair. An interruption mid-run leaves
		// a mixed state that maybe_migrate() detects on the next run and hands
		// to recover_partial(), which completes the remaining pairs safely.
		foreach ( $pairs as $pair ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- schema migration on own tables; identifiers bound via %i.
			$ok = $wpdb->query( $wpdb->prepare( 'RENAME TABLE %i TO %i', $pair[0], $pair[1] ) );
			if ( false === $ok ) {
				return self::error( "RENAME TABLE {$pair[0]} failed: " . $wpdb->last_error );
			}
		}

		if ( ! self::verify_all_target( $keys ) ) {
			return self::error( 'post-rename verification failed' );
		}

		Table_Map::set_active_prefix( Table_Map::TARGET_PREFIX );
		return self::result( 'migrated', count( $keys ) );
	}

	/**
	 * Complete a mixed/interrupted state table-by-table.
	 *
	 * For each core table: if only the legacy name exists, rename it; if only
	 * the target exists, it is already done; if both exist, abort (unsafe
	 * collision); if neither exists, leave it for the schema installer to
	 * create under the (now target) prefix.
	 *
	 * @since  0.2.0
	 * @param  string[] $keys Core table keys.
	 * @return array
	 */
	private static function recover_partial( $keys ) {
		global $wpdb;

		$pairs = array();
		foreach ( $keys as $key ) {
			$from     = Table_Map::name_with_prefix( $key, Table_Map::LEGACY_PREFIX );
			$to       = Table_Map::name_with_prefix( $key, Table_Map::TARGET_PREFIX );
			$has_from = self::table_exists( $from );
			$has_to   = self::table_exists( $to );

			if ( $has_from && $has_to ) {
				return self::error( "recovery: collision, both {$from} and {$to} exist" );
			}
			if ( $has_from && ! $has_to ) {
				$pairs[] = array( $from, $to );
			}
			// has_to only  -> already migrated for this table.
			// neither      -> missing; schema install recreates under target.
		}

		foreach ( $pairs as $pair ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- schema migration on own tables; identifiers bound via %i.
			if ( false === $wpdb->query( $wpdb->prepare( 'RENAME TABLE %i TO %i', $pair[0], $pair[1] ) ) ) {
				return self::error( "recovery RENAME {$pair[0]} failed: " . $wpdb->last_error );
			}
		}

		Table_Map::set_active_prefix( Table_Map::TARGET_PREFIX );
		return self::result( 'recovered', count( $pairs ) );
	}

	/**
	 * Verify every core table exists under the target prefix.
	 *
	 * @since  0.2.0
	 * @param  string[] $keys Core table keys.
	 * @return bool
	 */
	private static function verify_all_target( $keys ) {
		foreach ( $keys as $key ) {
			if ( ! self::table_exists( Table_Map::name_with_prefix( $key, Table_Map::TARGET_PREFIX ) ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Whether a physical table exists.
	 *
	 * @since  0.2.0
	 * @param  string $name Fully-qualified table name.
	 * @return bool
	 */
	private static function table_exists( $name ) {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- schema existence probe; caching would mask reality mid-migration.
		return (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $name ) );
	}

	/**
	 * Build a success/informational result.
	 *
	 * @since  0.2.0
	 * @param  string $status  Status token.
	 * @param  int    $renamed Tables renamed this run.
	 * @return array
	 */
	private static function result( $status, $renamed ) {
		return array(
			'status'  => $status,
			'renamed' => $renamed,
		);
	}

	/**
	 * Build an error result.
	 *
	 * @since  0.2.0
	 * @param  string $message Human-readable error.
	 * @return array
	 */
	private static function error( $message ) {
		return array(
			'status'  => 'error',
			'renamed' => 0,
			'error'   => $message,
		);
	}
}
