<?php

namespace SugarCalendarBookings\Migration;

use SugarCalendarBookings\Customers\CustomerModel;
use SugarCalendarBookings\Employees\EmployeeModel;
use SugarCalendarBookings\Locations\LocationModel;
use SugarCalendarBookings\Options;
use SugarCalendarBookings\Plugin;
use Throwable;
use WP_Error;

/**
 * Migration Service.
 *
 * Centralized service for migration logic, reusable across CLI, AJAX, and REST contexts.
 * This service is stateless - adapters must be passed to each method that requires them.
 *
 * @since 1.3.0
 */
class MigrationService {

	/**
	 * Option key for storing migration state.
	 *
	 * @since 1.3.0
	 *
	 * @var string
	 */
	public const MIGRATION_STATE_OPTION = 'scbookings_migration_state';

	/**
	 * Default batch size.
	 *
	 * @since 1.3.0
	 *
	 * @var int
	 */
	public const DEFAULT_BATCH_SIZE = 50;

	/**
	 * Issues storage instance for the current migration.
	 *
	 * @since 1.3.0
	 *
	 * @var MigrationIssuesStorage|null
	 */
	private ?MigrationIssuesStorage $issues_storage = null;

	/**
	 * Initialize issues storage for a new migration.
	 *
	 * Creates a new MigrationIssuesStorage instance and clears any existing issues.
	 *
	 * @since 1.3.0
	 *
	 * @param string $source_id Source adapter ID.
	 */
	private function init_issues_storage( string $source_id ): void {

		$this->issues_storage = new MigrationIssuesStorage( $source_id );

		$this->issues_storage->clear();
	}

	/**
	 * Build initial migration state structure.
	 *
	 * Creates the state array used to track migration progress.
	 *
	 * @since 1.3.0
	 *
	 * @param string $source_id              Source adapter ID.
	 * @param int    $batch_size             Batch size for processing.
	 * @param array  $entity_data            Entity counts data from get_entity_counts().
	 * @param bool   $include_batch_tracking Whether to include batch tracking fields (current_entity, current_offset).
	 *
	 * @return array<string, mixed> Migration state.
	 */
	private function build_initial_state( string $source_id, int $batch_size, array $entity_data, bool $include_batch_tracking = false ): array {

		$state = [
			'source_id'       => $source_id,
			'batch_size'      => $batch_size,
			'entities'        => $entity_data['entities'],
			'entity_order'    => $entity_data['entity_order'],
			'total_records'   => $entity_data['total_records'],
			'total_processed' => 0,
			'total_migrated'  => 0,
			'total_skipped'   => 0,
			'total_failed'    => 0,
			'status'          => 'running',
			'started_at'      => current_time( 'mysql' ),
		];

		// Add batch tracking fields for AJAX batch migrations.
		if ( $include_batch_tracking ) {
			$state['current_entity'] = 0;
			$state['current_offset'] = 0;
		}

		return $state;
	}

	/**
	 * Save migration results (metadata and issues).
	 *
	 * Saves migration metadata to the import option and issues to the issues option.
	 * This is the shared finalization logic used by both batch AJAX and CLI migrations.
	 *
	 * @since 1.3.0
	 *
	 * @param array<string, mixed> $state Migration state.
	 */
	private function save_migration_results( array $state ): void {

		// Build and save migration metadata.
		// Use merge=false to fully replace the previous migration data,
		// preventing deep merge from retaining stale entity results.
		$options     = Plugin::get( Options::class );
		$import_data = $options->get( 'import' ) ?: [];

		$import_data[ $state['source_id'] . '_last_migrated' ] = $this->build_migration_metadata( $state );

		$options->update(
			[
				'import' => $import_data,
			],
			false
		);

		// Save collected issues.
		if ( $this->issues_storage ) {
			$this->issues_storage->save();
		}
	}

	/**
	 * Record a fatal error and save failed migration state.
	 *
	 * Records the error in issues storage for CSV export and saves the migration
	 * as failed with zeroed statistics.
	 *
	 * @since 1.3.0
	 *
	 * @param string $error_message Error message to record.
	 */
	private function record_fatal_error( string $error_message ): void {

		$state = $this->get_state();

		if ( ! $state ) {
			return;
		}

		// Record fatal error in issues storage for CSV export.
		if ( $this->issues_storage ) {
			$this->issues_storage->add(
				'failed',
				'migration',
				0,
				esc_html__( 'Fatal Error', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				$error_message
			);
		}

		// Build failed state with zeroed stats and save results.
		$failed_state = $this->build_failed_state( $state );

		$this->save_migration_results( $failed_state );
	}

	/**
	 * Build a failed state with zeroed statistics.
	 *
	 * Creates a copy of the state with all entity and total statistics zeroed out
	 * and status set to failed.
	 *
	 * @since 1.3.0
	 *
	 * @param array<string, mixed> $state Original migration state.
	 *
	 * @return array<string, mixed> State with zeroed stats and failed status.
	 */
	private function build_failed_state( array $state ): array {

		// Zero out all entity stats.
		foreach ( array_keys( $state['entities'] ?? [] ) as $type ) {
			$state['entities'][ $type ]['migrated'] = 0;
			$state['entities'][ $type ]['skipped']  = 0;
			$state['entities'][ $type ]['failed']   = 0;
		}

		// Zero out totals and mark as failed.
		$state['total_migrated'] = 0;
		$state['total_skipped']  = 0;
		$state['total_failed']   = 0;
		$state['status']         = 'failed';

		return $state;
	}

	/**
	 * Validate the source adapter.
	 *
	 * @since 1.3.0
	 *
	 * @param SourceAdapterInterface $adapter Adapter to validate.
	 *
	 * @return true|WP_Error
	 */
	public function validate_source( SourceAdapterInterface $adapter ) {

		return $adapter->validate();
	}

	/**
	 * Get entity counts for migration initialization.
	 *
	 * @since 1.3.0
	 *
	 * @param SourceAdapterInterface $adapter          Source adapter.
	 * @param array<string>          $exclude_entities Entity types to exclude from migration.
	 *
	 * @return array{entities: array<string, array<string, mixed>>, entity_order: array<string>, total_records: int}
	 */
	public function get_entity_counts( SourceAdapterInterface $adapter, array $exclude_entities = [] ): array {

		$entities      = $adapter->get_supported_entities();
		$entity_counts = [];
		$entity_order  = [];
		$total_records = 0;

		foreach ( $entities as $entity_type ) {
			// Skip excluded entities.
			if ( in_array( $entity_type, $exclude_entities, true ) ) {
				continue;
			}

			$count = $adapter->count( $entity_type );

			if ( is_wp_error( $count ) ) {
				$count = 0;
			}

			$entity_counts[ $entity_type ] = [
				'total'     => $count,
				'processed' => 0,
				'migrated'  => 0,
				'skipped'   => 0,
				'failed'    => 0,
				'status'    => 'pending',
			];

			$entity_order[] = $entity_type;
			$total_records += $count;
		}

		return [
			'entities'      => $entity_counts,
			'entity_order'  => $entity_order,
			'total_records' => $total_records,
		];
	}

	/**
	 * Initialize migration state.
	 *
	 * @since 1.3.0
	 *
	 * @param string $source_id   Source ID.
	 * @param int    $batch_size  Batch size.
	 * @param array  $entity_data Entity counts data from get_entity_counts().
	 *
	 * @return array<string, mixed> Migration state.
	 */
	public function initialize_state( string $source_id, int $batch_size, array $entity_data ): array {

		// Initialize issues storage and clear any existing issues.
		$this->init_issues_storage( $source_id );

		// Build state with batch tracking fields for AJAX batch migrations.
		$state = $this->build_initial_state( $source_id, $batch_size, $entity_data, true );

		update_option( self::MIGRATION_STATE_OPTION, $state );

		return $state;
	}

	/**
	 * Get current migration state.
	 *
	 * @since 1.3.0
	 *
	 * @return array<string, mixed>|null
	 */
	public function get_state(): ?array {

		$state = get_option( self::MIGRATION_STATE_OPTION ) ?: null;

		// Ensure issues storage is initialized for batch processing.
		// Load existing issues so we can append to them across HTTP requests.
		if ( $state && ! empty( $state['source_id'] ) && $this->issues_storage === null ) {
			$this->issues_storage = new MigrationIssuesStorage( $state['source_id'], true );
		}

		return $state;
	}

	/**
	 * Update migration state.
	 *
	 * @since 1.3.0
	 *
	 * @param array<string, mixed> $state State to save.
	 */
	public function save_state( array $state ): void {

		update_option( self::MIGRATION_STATE_OPTION, $state );
	}

	/**
	 * Fetch a batch of items from source.
	 *
	 * @since 1.3.0
	 *
	 * @param SourceAdapterInterface $adapter     Source adapter.
	 * @param string                 $entity_type Entity type.
	 * @param int                    $limit       Batch size.
	 * @param int                    $offset      Offset.
	 *
	 * @return array<object>|WP_Error
	 */
	public function fetch_batch( SourceAdapterInterface $adapter, string $entity_type, int $limit, int $offset ) {

		return $adapter->fetch(
			$entity_type,
			[
				'limit'  => $limit,
				'offset' => $offset,
			]
		);
	}

	/**
	 * Process a single migration item.
	 *
	 * Existing records are updated by default during re-migration.
	 *
	 * @since 1.3.0
	 *
	 * @param SourceAdapterInterface $adapter     Source adapter.
	 * @param string                 $entity_type Entity type.
	 * @param object                 $raw_item    Raw item from source.
	 * @param bool                   $dry_run     Dry run mode.
	 *
	 * @throws Throwable If mapping or saving fails, the exception is re-thrown to abort migration.
	 *
	 * @return array{status: string, reason: string|null} Result with status and optional reason.
	 */
	public function process_item( SourceAdapterInterface $adapter, string $entity_type, object $raw_item, bool $dry_run = false ): array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		try {
			$context = [ 'author_id' => get_current_user_id() ?: 1 ];
			$mapped  = $adapter->map( $entity_type, $raw_item, $context );

			if ( $mapped === null ) {
				return [
					'status' => 'skipped',
					'reason' => __( 'Missing required relations', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				];
			}

			$existing = ModelRegistry::find_existing( $entity_type, $mapped );

			if ( $dry_run ) {
				return [
					'status' => 'migrated',
					'reason' => null,
				];
			}

			$model = $this->create_or_update_model( $entity_type, $mapped, $existing );

			if ( $model && $model->save() ) {
				if ( ! empty( $mapped['_amelia_id'] ) ) {
					$model->set_meta( '_amelia_id', $mapped['_amelia_id'] );
				}

				// Handle service relationships.
				if ( $entity_type === 'service' ) {
					$this->attach_service_relationships( $model, $mapped );
				}

				return [
					'status' => 'migrated',
					'reason' => null,
				];
			}

			return [
				'status' => 'failed',
				'reason' => __( 'Failed to save', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
			];
		} catch ( Throwable $e ) {
			$this->record_fatal_error( $e->getMessage() );

			// Re-throw to abort migration and show error modal.
			throw $e;
		}
	}

	/**
	 * Process a batch of items and return results.
	 *
	 * @since 1.3.0
	 *
	 * @param SourceAdapterInterface $adapter     Source adapter.
	 * @param string                 $entity_type Entity type.
	 * @param array<object>          $batch       Batch of items.
	 * @param bool                   $dry_run     Dry run mode.
	 *
	 * @return array{migrated: int, skipped: int, failed: int}
	 */
	public function process_batch_items( SourceAdapterInterface $adapter, string $entity_type, array $batch, bool $dry_run = false ): array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded

		// Check if this entity should be entirely skipped (e.g., employees for Amelia Pro → SCB Lite).
		$skip_all_entities = $this->get_skip_all_entities( $adapter );

		if ( isset( $skip_all_entities[ $entity_type ] ) ) {
			return $this->process_skip_all_batch( $entity_type, $batch, $skip_all_entities[ $entity_type ], $dry_run );
		}

		$results = [
			'migrated' => 0,
			'skipped'  => 0,
			'failed'   => 0,
		];

		foreach ( $batch as $raw_item ) {
			$result = $this->process_item( $adapter, $entity_type, $raw_item, $dry_run );
			$status = $result['status'];

			if ( $status === 'migrated' ) {
				++$results['migrated'];
			} elseif ( $status === 'skipped' ) {
				++$results['skipped'];

				// Collect issue details for skipped items.
				if ( ! $dry_run && $this->issues_storage ) {
					$this->issues_storage->add(
						'skipped',
						$entity_type,
						(int) ( $raw_item->id ?? 0 ),
						$this->get_item_name( $entity_type, $raw_item ),
						$result['reason'] ?? ''
					);
				}
			} else {
				++$results['failed'];

				// Collect issue details for failed items.
				if ( ! $dry_run && $this->issues_storage ) {
					$this->issues_storage->add(
						'failed',
						$entity_type,
						(int) ( $raw_item->id ?? 0 ),
						$this->get_item_name( $entity_type, $raw_item ),
						$result['reason'] ?? ''
					);
				}
			}
		}

		// Save issues after each batch to persist across HTTP requests.
		if ( ! $dry_run && $this->issues_storage ) {
			$this->issues_storage->save();
		}

		return $results;
	}

	/**
	 * Get entities that should be entirely skipped for this migration.
	 *
	 * Used for migrations where certain entity types are not supported in the target edition.
	 * For example, employees are not imported when migrating from Amelia Pro to SCB Lite.
	 *
	 * @since 1.3.0
	 *
	 * @param SourceAdapterInterface $adapter Source adapter.
	 *
	 * @return array<string, string> Entity type => skip reason.
	 */
	private function get_skip_all_entities( SourceAdapterInterface $adapter ): array {

		// For Amelia Pro → SCB Lite, skip all employees.
		if ( $adapter->get_id() === 'amelia'
			&& method_exists( $adapter, 'is_pro' )
			&& $adapter->is_pro()
			&& ! sugar_calendar_bookings()->is_pro()
		) {
			return [
				'employee' => esc_html__( 'Employees require Sugar Calendar Bookings Pro', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
			];
		}

		return [];
	}

	/**
	 * Process a batch where all items should be skipped.
	 *
	 * @since 1.3.0
	 *
	 * @param string        $entity_type Entity type.
	 * @param array<object> $batch       Batch of items.
	 * @param string        $skip_reason Reason for skipping.
	 * @param bool          $dry_run     Dry run mode.
	 *
	 * @return array{migrated: int, skipped: int, failed: int}
	 */
	private function process_skip_all_batch( string $entity_type, array $batch, string $skip_reason, bool $dry_run = false ): array {

		$results = [
			'migrated' => 0,
			'skipped'  => 0,
			'failed'   => 0,
		];

		foreach ( $batch as $raw_item ) {
			++$results['skipped'];

			if ( ! $dry_run && $this->issues_storage ) {
				$this->issues_storage->add(
					'skipped',
					$entity_type,
					(int) ( $raw_item->id ?? 0 ),
					$this->get_item_name( $entity_type, $raw_item ),
					$skip_reason
				);
			}
		}

		// Save issues after each batch to persist across HTTP requests.
		if ( ! $dry_run && $this->issues_storage ) {
			$this->issues_storage->save();
		}

		return $results;
	}

	/**
	 * Execute one batch step of a running migration.
	 *
	 * Advances the migration by one batch: resolves the current entity,
	 * fetches items, processes them, updates counters, and saves state.
	 * Reusable across AJAX and REST API contexts.
	 *
	 * @since 1.3.1
	 *
	 * @param array<string, mixed>   $state   Migration state (modified in place and saved).
	 * @param SourceAdapterInterface $adapter Source adapter.
	 *
	 * @return array{state: array<string, mixed>, is_complete: bool, current_entity: string|null}|WP_Error
	 */
	public function step_batch( array $state, SourceAdapterInterface $adapter ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$entity_order   = $state['entity_order'];
		$current_index  = $state['current_entity'];
		$current_offset = $state['current_offset'];
		$batch_size     = $state['batch_size'];

		// Check if migration is already complete.
		if ( $current_index >= count( $entity_order ) ) {
			return [
				'state'          => $state,
				'is_complete'    => true,
				'current_entity' => null,
			];
		}

		$entity_type  = $entity_order[ $current_index ];
		$entity_state = &$state['entities'][ $entity_type ];

		// Mark entity as in progress.
		if ( $entity_state['status'] === 'pending' ) {
			$entity_state['status'] = 'in_progress';
		}

		// Fetch and process batch.
		$batch = $this->fetch_batch( $adapter, $entity_type, $batch_size, $current_offset );

		if ( is_wp_error( $batch ) ) {
			$this->abort_migration( $batch->get_error_message() );

			return $batch;
		}

		if ( ! empty( $batch ) ) {
			$batch_results = $this->process_batch_items( $adapter, $entity_type, $batch, false );
			$batch_count   = count( $batch );

			$entity_state['processed'] += $batch_count;
			$state['total_processed']  += $batch_count;

			$entity_state['migrated'] += $batch_results['migrated'];
			$state['total_migrated']  += $batch_results['migrated'];

			$entity_state['skipped'] += $batch_results['skipped'];
			$state['total_skipped']  += $batch_results['skipped'];

			$entity_state['failed'] += $batch_results['failed'];
			$state['total_failed']  += $batch_results['failed'];

			$state['current_offset'] = $current_offset + $batch_count;
		}

		// Check if current entity is complete.
		if ( empty( $batch ) || $entity_state['processed'] >= $entity_state['total'] ) {
			$entity_state['status'] = 'completed';

			++$state['current_entity'];
			$state['current_offset'] = 0;
		}

		$is_complete = $state['current_entity'] >= count( $entity_order );

		$this->save_state( $state );

		return [
			'state'          => $state,
			'is_complete'    => $is_complete,
			'current_entity' => $entity_type,
		];
	}

	/**
	 * Complete the migration and save final state.
	 *
	 * @since 1.3.0
	 *
	 * @param array<string, mixed> $state Migration state.
	 *
	 * @return array<string, mixed> Updated state.
	 */
	public function complete_migration( array $state ): array {

		// Preserve 'failed' status from abort_migration(); only mark 'completed' if still running.
		if ( $state['status'] !== 'failed' ) {
			$state['status'] = 'completed';
		}

		$state['finished_at'] = current_time( 'mysql' );

		// Save state to WP option for batch AJAX status checks.
		$this->save_state( $state );

		// Save migration metadata and issues.
		$this->save_migration_results( $state );

		return $state;
	}

	/**
	 * Determine the overall migration result status from state counters.
	 *
	 * @since 1.3.1
	 *
	 * @param array<string, mixed> $state Migration state.
	 *
	 * @return string 'failed', 'warning', or 'success'.
	 */
	public function determine_result_status( array $state ): string {

		// Use pre-set failed status (from fatal errors), otherwise determine from results.
		if ( isset( $state['status'] ) && $state['status'] === 'failed' ) {
			return 'failed';
		}

		if ( $state['total_failed'] > 0 ) {
			return 'failed';
		}

		if ( $state['total_skipped'] > 0 ) {
			return 'warning';
		}

		return 'success';
	}

	/**
	 * Build per-entity report from state.
	 *
	 * Extracts migrated/skipped/failed counts for each entity type.
	 *
	 * @since 1.3.1
	 *
	 * @param array<string, mixed> $state Migration state.
	 *
	 * @return array<string, array{migrated: int, skipped: int, failed: int}>
	 */
	public function build_entity_report( array $state ): array {

		$report = [];

		foreach ( $state['entities'] as $entity_type => $entity_data ) {
			$report[ $entity_type ] = [
				'migrated' => $entity_data['migrated'] ?? 0,
				'skipped'  => $entity_data['skipped'] ?? 0,
				'failed'   => $entity_data['failed'] ?? 0,
			];
		}

		return $report;
	}

	/**
	 * Build migration metadata for storage.
	 *
	 * @since 1.3.0
	 *
	 * @param array<string, mixed> $state Migration state.
	 *
	 * @return array<string, mixed> Migration metadata.
	 */
	private function build_migration_metadata( array $state ): array {

		return [
			'date'           => current_time( 'mysql' ),
			'status'         => $this->determine_result_status( $state ),
			'scb_version'    => sugar_calendar_bookings()->is_pro() ? 'pro' : 'lite',
			'amelia_version' => $this->detect_amelia_version( $state['source_id'] ),
			'stats'          => [
				'total_migrated' => $state['total_migrated'],
				'total_skipped'  => $state['total_skipped'],
				'total_failed'   => $state['total_failed'],
			],
			'entities'       => $this->build_entity_report( $state ),
		];
	}

	/**
	 * Detect Amelia version (Pro or Lite).
	 *
	 * @since 1.3.0
	 *
	 * @param string $source_id Source adapter ID.
	 *
	 * @return string 'pro' or 'lite'.
	 */
	private function detect_amelia_version( string $source_id ): string {

		if ( $source_id !== 'amelia' ) {
			return 'lite';
		}

		$adapter = Plugin::get( MigrationRegistry::class )->get_adapter( 'amelia' );

		if ( $adapter && method_exists( $adapter, 'is_pro' ) ) {
			return $adapter->is_pro() ? 'pro' : 'lite';
		}

		return 'lite';
	}

	/**
	 * Abort the migration with error.
	 *
	 * Records the fatal error in issues storage so it appears in the CSV export,
	 * then marks the migration as failed.
	 *
	 * @since 1.3.0
	 *
	 * @param string $message Error message.
	 *
	 * @return array<string, mixed>|null Updated state or null.
	 */
	public function abort_migration( string $message ): ?array {

		$state = $this->get_state();

		if ( $state ) {
			$state['status'] = 'failed';
			$state['error']  = $message; // phpcs:ignore WPForms.Formatting.EmptyLineAfterAssigmentVariables.AddEmptyLine
			$this->save_state( $state );
		}

		return $state;
	}

	/**
	 * Calculate progress percentage.
	 *
	 * @since 1.3.0
	 *
	 * @param int $processed     Processed count.
	 * @param int $total_records Total records.
	 *
	 * @return float
	 */
	public function calculate_progress( int $processed, int $total_records ): float {

		return $total_records > 0
			? round( ( $processed / $total_records ) * 100, 1 )
			: 100;
	}

	/**
	 * Get stored issues from the database.
	 *
	 * @since 1.3.0
	 *
	 * @param string $source_id Source adapter ID.
	 *
	 * @return array<array<string, mixed>>
	 */
	public function get_stored_issues( string $source_id ): array {

		return MigrationIssuesStorage::get_stored_for_source( $source_id );
	}

	/**
	 * Check if there are stored issues for a source.
	 *
	 * @since 1.3.0
	 *
	 * @param string $source_id Source adapter ID.
	 *
	 * @return bool
	 */
	public function has_stored_issues( string $source_id ): bool {

		return MigrationIssuesStorage::has_stored_for_source( $source_id );
	}

	/**
	 * Extract item name from raw source data.
	 *
	 * @since 1.3.0
	 *
	 * @param string $entity_type Entity type.
	 * @param object $raw_item    Raw item from source.
	 *
	 * @return string
	 */
	public function get_item_name( string $entity_type, object $raw_item ): string { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded

		// phpcs:ignore WPForms.Formatting.Switch.AddEmptyLineBefore
		switch ( $entity_type ) {
			case 'service':
			case 'location':
				return $raw_item->name ?? ''; // phpcs:ignore WPForms.Formatting.EmptyLineBeforeReturn.AddEmptyLineBeforeReturnStatement

			case 'customer':
			case 'employee':
				$first_name = $raw_item->firstName ?? ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
				$last_name  = $raw_item->lastName ?? ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
				$name       = trim( $first_name . ' ' . $last_name );

				// Fallback to email if name is empty.
				return $name ?: ( $raw_item->email ?? '' );

			case 'appointment':
				return sprintf( // phpcs:ignore WPForms.Formatting.EmptyLineBeforeReturn.AddEmptyLineBeforeReturnStatement
					/* translators: %1$d - booking ID from source system. */
					__( 'Booking #%1$d', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					$raw_item->id ?? 0
				);

			default:
				return (string) ( $raw_item->id ?? '' ); // phpcs:ignore WPForms.Formatting.EmptyLineBeforeReturn.AddEmptyLineBeforeReturnStatement
		}

		// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
	}

	/**
	 * Run full migration for all entities.
	 *
	 * This is a convenience method for CLI and legacy AJAX usage that processes
	 * all entities in dependency order, tracks issues, and saves full migration
	 * metadata on completion.
	 *
	 * @since 1.3.0
	 *
	 * @param SourceAdapterInterface $adapter            Source adapter.
	 * @param array<string, mixed>   $options            Options: dry_run, batch_size, exclude_entities, entities.
	 * @param callable|null          $on_entity          Callback for entity start: fn(string $entity, int $step, int $total).
	 * @param callable|null          $on_batch           Callback for batch complete: fn(string $entity, array $batch_results).
	 * @param callable|null          $on_entity_complete Callback for entity complete: fn(string $entity, MigrationReport $report).
	 *
	 * @return array<string, MigrationReport>
	 */
	public function run_full_migration( SourceAdapterInterface $adapter, array $options = [], ?callable $on_entity = null, ?callable $on_batch = null, ?callable $on_entity_complete = null ): array {

		// Prepare options and compute entity exclusions.
		$options          = $this->prepare_migration_options( $options, $adapter );
		$source_id        = $adapter->get_id();
		$exclude_entities = $options['exclude_entities'];

		// Initialize issues storage for tracking skipped/failed items (only for live migrations).
		if ( ! $options['dry_run'] ) {
			$this->init_issues_storage( $source_id );
		}

		// Get entity counts and build initial state.
		$entity_data = $this->get_entity_counts( $adapter, $exclude_entities );
		$entities    = $entity_data['entity_order'];
		$state       = $this->build_initial_state( $source_id, $options['batch_size'], $entity_data );

		// Process all entities in dependency order.
		$reports = $this->process_all_entities(
			$adapter,
			$entities,
			$options,
			$state,
			$on_entity,
			$on_batch,
			$on_entity_complete
		);

		// Finalize migration (save metadata and issues) for live migrations.
		if ( ! $options['dry_run'] ) {
			$this->finalize_full_migration( $state );
		}

		return $reports;
	}

	/**
	 * Prepare migration options with defaults and computed exclusions.
	 *
	 * @since 1.3.0
	 *
	 * @param array<string, mixed>   $options User-provided options.
	 * @param SourceAdapterInterface $adapter Source adapter.
	 *
	 * @return array<string, mixed> Prepared options.
	 */
	private function prepare_migration_options( array $options, SourceAdapterInterface $adapter ): array {

		$defaults = [
			'dry_run'          => false,
			'batch_size'       => self::DEFAULT_BATCH_SIZE,
			'exclude_entities' => [],
			'entities'         => [],
		];

		$options = wp_parse_args( $options, $defaults );

		// If specific entities are requested, exclude all others.
		if ( ! empty( $options['entities'] ) ) {
			$all_entities                = $adapter->get_supported_entities();
			$options['exclude_entities'] = array_diff( $all_entities, $options['entities'] );
		}

		return $options;
	}

	/**
	 * Process all entities in dependency order.
	 *
	 * @since 1.3.0
	 *
	 * @param SourceAdapterInterface $adapter            Source adapter.
	 * @param array<string>          $entities           Entity types to process.
	 * @param array<string, mixed>   $options            Migration options.
	 * @param array<string, mixed>   $state              Migration state (passed by reference for updates).
	 * @param callable|null          $on_entity          Callback for entity start.
	 * @param callable|null          $on_batch           Callback for batch complete.
	 * @param callable|null          $on_entity_complete Callback for entity complete.
	 *
	 * @return array<string, MigrationReport>
	 */
	private function process_all_entities(
		SourceAdapterInterface $adapter,
		array $entities,
		array $options,
		array &$state,
		?callable $on_entity,
		?callable $on_batch,
		?callable $on_entity_complete
	): array {

		$reports     = [];
		$step        = 1;
		$total_steps = count( $entities );

		foreach ( $entities as $entity_type ) {
			if ( $on_entity ) {
				$on_entity( $entity_type, $step, $total_steps );
			}

			$report                  = $this->migrate_entity( $adapter, $entity_type, $options, $on_batch );
			$reports[ $entity_type ] = $report;

			// Update state with entity results.
			$this->update_state_from_report( $state, $entity_type, $report );

			if ( $on_entity_complete ) {
				$on_entity_complete( $entity_type, $report );
			}

			++$step;
		}

		return $reports;
	}

	/**
	 * Update migration state with results from an entity report.
	 *
	 * @since 1.3.0
	 *
	 * @param array<string, mixed> $state       Migration state (passed by reference).
	 * @param string               $entity_type Entity type that was processed.
	 * @param MigrationReport      $report      Migration report for the entity.
	 */
	private function update_state_from_report( array &$state, string $entity_type, MigrationReport $report ): void {

		$report_data = $report->get_data();

		$state['entities'][ $entity_type ] = [
			'total'     => $report_data['total'],
			'processed' => $report_data['total'],
			'migrated'  => $report_data['migrated'],
			'skipped'   => $report_data['skipped'],
			'failed'    => $report_data['failed'],
			'status'    => 'completed',
		];

		$state['total_processed'] += $report_data['total'];
		$state['total_migrated']  += $report_data['migrated'];
		$state['total_skipped']   += $report_data['skipped'];
		$state['total_failed']    += $report_data['failed'];
	}

	/**
	 * Finalize a full migration by saving metadata and issues.
	 *
	 * This is called at the end of run_full_migration() for live migrations.
	 * It saves the same comprehensive metadata that the batch AJAX migration saves.
	 *
	 * @since 1.3.0
	 *
	 * @param array<string, mixed> $state Migration state.
	 */
	private function finalize_full_migration( array $state ): void {

		$state['status']      = 'completed';
		$state['finished_at'] = current_time( 'mysql' );

		// Save migration metadata and issues (no state persistence needed for CLI).
		$this->save_migration_results( $state );
	}

	/**
	 * Migrate a single entity type.
	 *
	 * @since 1.3.0
	 *
	 * @param SourceAdapterInterface $adapter     Source adapter.
	 * @param string                 $entity_type Entity type.
	 * @param array<string, mixed>   $options     Options.
	 * @param callable|null          $on_batch    Callback for batch complete.
	 *
	 * @return MigrationReport
	 */
	public function migrate_entity( $adapter, $entity_type, $options = [], $on_batch = null ): MigrationReport { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$report = new MigrationReport();

		$report->start();

		// Get total count.
		$total = $adapter->count( $entity_type );

		if ( is_wp_error( $total ) ) {
			$report->add_error( $total->get_error_message() );
			$report->stop();

			return $report;
		}

		$report->set_total( $total );

		// Process in batches.
		$offset     = 0;
		$batch_size = $options['batch_size'] ?? self::DEFAULT_BATCH_SIZE;
		$dry_run    = $options['dry_run'] ?? false;

		while ( $offset < $total ) {
			$batch = $this->fetch_batch( $adapter, $entity_type, $batch_size, $offset );

			if ( is_wp_error( $batch ) ) {
				$report->add_error( $batch->get_error_message() );
				break;
			}

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

			$batch_results = $this->process_batch_items( $adapter, $entity_type, $batch, $dry_run );

			// Update report with batch counts.
			$report->add_migrated( $batch_results['migrated'] );
			$report->add_skipped( $batch_results['skipped'] );
			$report->add_failed( $batch_results['failed'] );

			if ( $on_batch ) {
				$on_batch( $entity_type, $batch_results );
			}

			$offset += $batch_size;

			// Free memory.
			unset( $batch );

			if ( function_exists( 'wp_cache_flush' ) ) {
				wp_cache_flush();
			}
		}

		$report->stop();

		return $report;
	}

	/**
	 * Create or update model.
	 *
	 * @since 1.3.0
	 *
	 * @param string               $entity_type Entity type.
	 * @param array<string, mixed> $mapped      Mapped data.
	 * @param object|null          $existing    Existing model.
	 *
	 * @return object|null
	 */
	private function create_or_update_model( string $entity_type, array $mapped, $existing = null ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$model_class = ModelRegistry::get_class( $entity_type );

		if ( ! $model_class ) {
			return null;
		}

		// Handle duplicate phone for customers and employees.
		if ( in_array( $entity_type, [ 'customer', 'employee' ], true ) && ! $existing && ! empty( $mapped['phone'] ) ) {

			$phone_model = $entity_type === 'employee' ? EmployeeModel::class : CustomerModel::class;

			$phone_exists = $phone_model::query()
				->where( 'phone', '=', $mapped['phone'], '%s' )
				->first();

			if ( $phone_exists ) {
				$mapped['phone'] = null;
			}
		}

		if ( $existing ) {
			foreach ( $mapped as $key => $value ) {
				if ( strpos( $key, '_' ) !== 0 ) {
					$existing->$key = $value;
				}
			}

			return $existing;
		}

		return new $model_class( $mapped );
	}

	/**
	 * Attach service relationships.
	 *
	 * @since 1.3.0
	 *
	 * @param object               $service Service model.
	 * @param array<string, mixed> $mapped  Mapped data.
	 */
	private function attach_service_relationships( $service, array $mapped ): void {

		$this->sync_service_locations( $service, $mapped['_amelia_location_ids'] ?? [] );
		$this->sync_service_employees( $service, $mapped['_amelia_employee_ids'] ?? [] );
	}

	/**
	 * Sync service locations from Amelia migration data.
	 *
	 * Uses sync() instead of attach() to handle re-migrations properly,
	 * ensuring stale relationships are removed when locations are removed in Amelia.
	 *
	 * @since 1.3.0
	 *
	 * @param object     $service             Service model.
	 * @param array<int> $amelia_location_ids Amelia location IDs.
	 */
	private function sync_service_locations( $service, array $amelia_location_ids ): void {

		if ( empty( $amelia_location_ids ) ) {
			$this->sync_default_location( $service );

			return;
		}

		$sync_locations = [];

		foreach ( $amelia_location_ids as $order => $amelia_id ) {
			$location = ModelRegistry::find_by_amelia_id( 'location', (int) $amelia_id );

			if ( $location ) {
				$sync_locations[ $location->id ] = [ 'location_order' => $order ];
			}
		}

		if ( empty( $sync_locations ) ) {
			$this->sync_default_location( $service );

			return;
		}

		$service->locations()->sync( $sync_locations, [ 'location_order' => '%d' ] );
	}

	/**
	 * Sync service employees from Amelia migration data.
	 *
	 * Uses sync() instead of attach() to handle re-migrations properly,
	 * ensuring stale relationships are removed when employees are removed in Amelia.
	 *
	 * @since 1.3.0
	 *
	 * @param object     $service             Service model.
	 * @param array<int> $amelia_employee_ids Amelia employee IDs.
	 */
	private function sync_service_employees( $service, array $amelia_employee_ids ): void {

		if ( empty( $amelia_employee_ids ) ) {
			$this->sync_default_employee( $service );

			return;
		}

		$employee_ids = [];

		foreach ( $amelia_employee_ids as $amelia_id ) {
			$employee = ModelRegistry::find_by_amelia_id( 'employee', (int) $amelia_id );

			if ( $employee ) {
				$employee_ids[] = $employee->id;
			}
		}

		if ( empty( $employee_ids ) ) {
			$this->sync_default_employee( $service );

			return;
		}

		$service->employees()->sync( $employee_ids );
	}

	/**
	 * Sync service with the default location.
	 *
	 * Used when Amelia has no locations (Amelia Lite) or when
	 * Amelia locations cannot be found in SCB (SCB Lite).
	 *
	 * @since 1.3.0
	 *
	 * @param object $service Service model.
	 */
	private function sync_default_location( $service ): void {

		$default_location = LocationModel::get_or_create_default();

		if ( ! $default_location ) {
			return;
		}

		$service->locations()->sync(
			[ $default_location->id => [ 'location_order' => 0 ] ],
			[ 'location_order' => '%d' ]
		);
	}

	/**
	 * Sync service with the default employee.
	 *
	 * Used when Amelia has no employees (Amelia Lite) or when
	 * Amelia employees cannot be found in SCB (SCB Lite).
	 *
	 * @since 1.3.0
	 *
	 * @param object $service Service model.
	 */
	private function sync_default_employee( $service ): void {

		$default_employee = EmployeeModel::get_or_create_default();

		if ( ! $default_employee ) {
			return;
		}

		$service->employees()->sync( [ $default_employee->id ] );
	}
}
