<?php
/**
 * DemoImportService — installs, removes and switches Prism demo packages.
 *
 * Counterpart of DemoExportService (DEMO_IMPORT_PLAN.md). Implements the
 * "fitting room" model decided at S470/S471:
 *
 *   - INSTALL a package (catalog/design layer) + GENERATE the sample layer
 *     (customers, orders, order items, reviews) with dates spread over the
 *     trailing 30 days so the dashboard looks alive on any install date.
 *   - Every written row is recorded in tc_demo_import_log keyed by the
 *     package's stable source key → re-running the same package is an
 *     idempotent REPAIR (#677), and removal deletes exactly the logged rows.
 *   - SWITCH = dirty-check → remove current preset → install the new one.
 *     Dirty ⇔ a logged row's fingerprint changed, OR catalog rows exist that
 *     are not in the log, OR orders exist that are not in the log. A dirty
 *     store is LOCKED: switching is refused and a one-time admin notice is
 *     raised so the user learns at customisation time.
 *   - CLEAR SAMPLE DATA removes only the transactional entity types (the
 *     dashboard/Revenue-Reports pollution); catalog + design stay.
 *
 * Progress: work is chunked into a client-stepped JOB (state in one option +
 * the package cached on disk). The REST controller creates the job and the
 * admin UI calls `step` until `done` — same shape as the CSV import jobs,
 * without WP-Cron (the user is sitting on the page waiting anyway).
 *
 * Media: every image goes through ImportService::sideloadRemoteImage() —
 * the plugin's single remote-fetch path (tripwire-allowlisted); this file
 * never downloads anything itself. Package media resolve against the
 * package's own URL (hosted on market.titancart.io) with the exporter's
 * original URL as fallback.
 *
 * WordPress usage is limited to the thin layer: options for job/state,
 * wp_remote_get for the manifest, attachments via the sideload helper.
 *
 * @package TitanCart\Modules\ImportExport
 */

namespace TitanCart\Modules\ImportExport;

use TitanCart\DB\Database;
use TitanCart\Modules\Products\CategoryRepository;
use TitanCart\Services\SettingsService;

defined( 'ABSPATH' ) || exit;

class DemoImportService {

	const FORMAT = 'titancart-demo/1';

	/** Catalog/design layer — kept by the user, removed only on switch. */
	const LAYER_CATALOG = [ 'brand', 'category', 'feature', 'feature_variant', 'product', 'product_group', 'tag', 'attachment', 'theme_option' ];

	/** Transactional layer — "Clear demo sample data" target. */
	const LAYER_SAMPLE = [ 'review', 'order_item', 'order', 'customer' ];

	/** Install phases in execution order (products and theme are the chunked ones). */
	const PHASES = [ 'brands', 'categories', 'features', 'products', 'groups', 'theme', 'sample', 'finish' ];

	const OPT_INSTALLED   = 'titancart_demo_installed';   // { preset, installed_at, package_url }
	const OPT_JOB         = 'titancart_demo_job';
	const OPT_LOCK_NOTICE = 'titancart_demo_lock_notice'; // '1' once the lock has been detected + shown

	/** Products written per step (each may sideload several images). */
	const PRODUCT_CHUNK = 5;

	/**
	 * Theme banner images sideloaded per step (S472). The theme phase used to
	 * fetch every `{{media:…}}` banner in ONE request — 40 on the default preset —
	 * which pinned the progress bar at 63% for ~20 s and flirted with PHP/proxy
	 * timeouts on slow hosts. media() is idempotent through the import log, so
	 * the banners are pre-fetched in slices and installThemeOptions() then
	 * resolves them instantly.
	 */
	const THEME_MEDIA_CHUNK = 4;

	/** Sample-layer sizing. */
	const SAMPLE_DAYS      = 30;
	const SAMPLE_CUSTOMERS = 24;
	const SAMPLE_ORDERS    = 60;
	const SAMPLE_REVIEWS   = 40;

	private Database $db;
	private string $log;

	/** @var array<string,array<string,int>> entity_type → source_key → local_id (per preset, lazily loaded) */
	private array $log_cache = [];
	private string $log_cache_preset = '';

	public function __construct() {
		$this->db  = titancart_db();
		$this->log = $this->db->table( 'demo_import_log' );
	}

	// ══════════════════════════════════════════════════════════════════════════
	// Status / dirty check
	// ══════════════════════════════════════════════════════════════════════════

	/**
	 * @return array{ installed:?array, locked:bool, dirty:string[], counts:array<string,int>, job:?array }
	 */
	public function status(): array {
		$installed = get_option( self::OPT_INSTALLED, null );
		$installed = is_array( $installed ) ? $installed : null;
		$dirty     = $installed ? $this->dirtyReasons( (string) $installed['preset'] ) : $this->dirtyReasons( '' );
		$counts    = [];
		if ( $installed ) {
			foreach ( $this->db->getArray( "SELECT entity_type, COUNT(*) AS n FROM {$this->log} WHERE preset = ?s GROUP BY entity_type", $installed['preset'] ) as $r ) {
				$counts[ $r['entity_type'] ] = (int) $r['n'];
			}
		}
		$job = get_option( self::OPT_JOB, null );
		return [
			'installed' => $installed,
			'locked'    => ! empty( $dirty ),
			'dirty'     => $dirty,
			'counts'    => $counts,
			'job'       => is_array( $job ) ? $job : null,
		];
	}

	/**
	 * Why the store is considered customised (empty = pristine).
	 *
	 * @param  string $preset  Installed preset ('' = nothing installed: dirty iff
	 *                         the store already has catalog rows or orders).
	 * @return string[]
	 */
	public function dirtyReasons( string $preset ): array {
		$reasons = [];
		$p  = $this->db->table( 'products' );
		$c  = $this->db->table( 'categories' );
		$o  = $this->db->table( 'orders' );

		$unlogged = function ( string $table, string $type ) use ( $preset ): int {
			if ( '' === $preset ) {
				return (int) $this->db->getField( "SELECT COUNT(*) FROM {$table} t WHERE t.deleted_at IS NULL" );
			}
			return (int) $this->db->getField(
				"SELECT COUNT(*) FROM {$table} t
				  WHERE t.deleted_at IS NULL
				    AND NOT EXISTS ( SELECT 1 FROM {$this->log} l WHERE l.preset = ?s AND l.entity_type = ?s AND l.local_id = t.id )",
				$preset,
				$type
			);
		};

		$n = $unlogged( $p, 'product' );
		if ( $n > 0 ) {
			$reasons[] = sprintf( '%d product(s) were created outside the demo', $n );
		}
		$n = $unlogged( $c, 'category' );
		if ( $n > 0 ) {
			$reasons[] = sprintf( '%d categor%s created outside the demo', $n, 1 === $n ? 'y was' : 'ies were' );
		}
		$n = $unlogged( $o, 'order' );
		if ( $n > 0 ) {
			$reasons[] = sprintf( '%d real order(s) exist', $n );
		}

		if ( '' !== $preset ) {
			$changed = 0;
			$rows    = $this->db->getArray(
				"SELECT entity_type, local_id, source_key, fingerprint FROM {$this->log}
				  WHERE preset = ?s AND entity_type IN (?a) AND fingerprint IS NOT NULL",
				$preset,
				[ 'product', 'category', 'theme_option' ]
			);
			foreach ( $rows as $r ) {
				$now = $this->fingerprint( (string) $r['entity_type'], (int) $r['local_id'], (string) $r['source_key'], $preset );
				if ( null !== $now && $now !== $r['fingerprint'] ) {
					$changed++;
				}
			}
			if ( $changed > 0 ) {
				$reasons[] = sprintf( '%d demo item(s) were edited', $changed );
			}
		}

		if ( ! empty( $reasons ) && '' !== $preset && '1' !== get_option( self::OPT_LOCK_NOTICE, '' ) ) {
			update_option( self::OPT_LOCK_NOTICE, '1', false );
		}
		return $reasons;
	}

	/**
	 * Hash of the demo-relevant fields of a live row (NULL = row gone).
	 * Computed identically at import time and at dirty-check time.
	 */
	private function fingerprint( string $type, int $local_id, string $source_key, string $preset ): ?string {
		switch ( $type ) {
			case 'product':
				$row = $this->db->getRow(
					"SELECT p.sku, p.slug, p.price, p.list_price, p.status, p.stock_quantity, t.name, t.description, t.short_desc
					   FROM {$this->db->table('products')} p
					   LEFT JOIN {$this->db->table('product_translations')} t ON t.product_id = p.id AND t.language_code = 'en'
					  WHERE p.id = ?i AND p.deleted_at IS NULL",
					$local_id
				);
				break;
			case 'category':
				$row = $this->db->getRow(
					"SELECT c.slug, c.parent_id, c.status, c.image_id, t.name, t.description
					   FROM {$this->db->table('categories')} c
					   LEFT JOIN {$this->db->table('category_translations')} t ON t.category_id = c.id AND t.language_code = 'en'
					  WHERE c.id = ?i AND c.deleted_at IS NULL",
					$local_id
				);
				break;
			case 'theme_option':
				SettingsService::flush( 'theme_options' );
				$map = SettingsService::section( 'theme_options' );
				$key = $preset . '_' . $source_key;
				$row = array_key_exists( $key, $map ) ? [ 'value' => $map[ $key ] ] : null;
				break;
			default:
				return null;
		}
		if ( ! $row ) {
			return null;
		}
		return sha1( wp_json_encode( $row ) );
	}

	// ══════════════════════════════════════════════════════════════════════════
	// Package fetch
	// ══════════════════════════════════════════════════════════════════════════

	/**
	 * Fetch + validate a hosted package.json and cache it on disk for the job.
	 *
	 * @return array{ path:string, base_url:string, preset:string, counts:array }
	 * @throws \RuntimeException
	 */
	public function fetchPackage( string $url ): array {
		if ( ! preg_match( '#^https://#i', $url ) ) {
			throw new \RuntimeException( 'Demo package URL must be https.' );
		}
		$resp = wp_remote_get( $url, [ 'timeout' => 60 ] );
		if ( is_wp_error( $resp ) ) {
			throw new \RuntimeException( 'Could not download the demo package: ' . $resp->get_error_message() );
		}
		if ( 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
			throw new \RuntimeException( 'Demo package returned HTTP ' . wp_remote_retrieve_response_code( $resp ) . '.' );
		}
		$body = wp_remote_retrieve_body( $resp );
		$pkg  = json_decode( $body, true );
		if ( ! is_array( $pkg ) || ( $pkg['format'] ?? '' ) !== self::FORMAT || empty( $pkg['preset'] ) || empty( $pkg['products'] ) ) {
			throw new \RuntimeException( 'That file is not a valid TitanCart demo package.' );
		}
		$preset = preg_replace( '/[^a-z0-9_]/', '', strtolower( (string) $pkg['preset'] ) );

		$dir = $this->cacheDir();
		$path = $dir . '/' . $preset . '.json';
		file_put_contents( $path, $body ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- self-owned cache under uploads/titancart; WP_Filesystem init is disproportionate in REST context.

		return [
			'path'     => $path,
			'base_url' => rtrim( dirname( $url ), '/' ) . '/',
			'preset'   => $preset,
			'counts'   => (array) ( $pkg['counts'] ?? [] ),
		];
	}

	private function cacheDir(): string {
		$upload = wp_upload_dir();
		$dir    = $upload['basedir'] . '/titancart/demo-cache';
		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
			file_put_contents( $dir . '/.htaccess', "Options -Indexes\nDeny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- tiny self-owned .htaccess.
		}
		return $dir;
	}

	private function loadCached( string $path ): array {
		$pkg = is_readable( $path ) ? json_decode( (string) file_get_contents( $path ), true ) : null; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local cache file we just wrote.
		if ( ! is_array( $pkg ) ) {
			throw new \RuntimeException( 'Cached demo package is missing — start the install again.' );
		}
		return $pkg;
	}

	// ══════════════════════════════════════════════════════════════════════════
	// Jobs (client-stepped)
	// ══════════════════════════════════════════════════════════════════════════

	/**
	 * Create an install/switch job. Refuses when the store is locked and a
	 * different preset is already installed (the fitting-room rule).
	 *
	 * @param  string $url    Hosted package.json URL.
	 * @param  bool   $force  Skip the dirty check (explicit "I understand" in the UI).
	 * @return array  Job state.
	 * @throws \RuntimeException
	 */
	public function startInstall( string $url, bool $force = false ): array {
		$existing = get_option( self::OPT_JOB, null );
		if ( is_array( $existing ) && 'running' === ( $existing['status'] ?? '' ) && ( time() - (int) ( $existing['touched_at'] ?? 0 ) ) < 120 ) {
			throw new \RuntimeException( 'A demo install is already running.' );
		}

		$fetched   = $this->fetchPackage( $url );
		$installed = get_option( self::OPT_INSTALLED, null );
		$current   = is_array( $installed ) ? (string) $installed['preset'] : '';
		$switching = ( '' !== $current && $current !== $fetched['preset'] );

		if ( ! $force ) {
			$dirty = $this->dirtyReasons( $current );
			if ( $switching && ! empty( $dirty ) ) {
				throw new \RuntimeException( 'This store has been customised, so the demo can no longer be switched: ' . implode( '; ', $dirty ) . '.' );
			}
		}

		$job = [
			'status'      => 'running',
			'preset'      => $fetched['preset'],
			'package'     => $fetched['path'],
			'base_url'    => $fetched['base_url'],
			'package_url' => $url,
			'remove_first' => $switching ? $current : '',
			'phase'       => $switching ? 'remove' : self::PHASES[0],
			'offset'      => 0,
			'total'       => (int) ( $fetched['counts']['products'] ?? 0 ),
			'done_items'  => 0,
			'started_at'  => time(),
			'touched_at'  => time(),
			'error'       => '',
			'warnings'    => [],
		];
		update_option( self::OPT_JOB, $job, false );
		return $job;
	}

	/**
	 * Run the next slice of the active job. Returns the updated job state with
	 * `percent` and `message`; `status` becomes 'done' or 'error'.
	 */
	public function step(): array {
		$job = get_option( self::OPT_JOB, null );
		if ( ! is_array( $job ) || 'running' !== ( $job['status'] ?? '' ) ) {
			return $this->decorate( is_array( $job ) ? $job : [ 'status' => 'idle' ] );
		}

		try {
			$pkg = $this->loadCached( (string) $job['package'] );
			$this->log_cache_preset = '';

			switch ( $job['phase'] ) {
				case 'remove':
					$this->remove( (string) $job['remove_first'] );
					$job['phase'] = self::PHASES[0];
					break;

				case 'brands':
					$this->installBrands( $pkg, $job );
					$job['phase'] = 'categories';
					break;

				case 'categories':
					$this->installCategories( $pkg, $job );
					$job['phase'] = 'features';
					break;

				case 'features':
					$this->installFeatures( $pkg, $job );
					$job['phase']  = 'products';
					$job['offset'] = 0;
					break;

				case 'products':
					$slice = array_slice( (array) $pkg['products'], (int) $job['offset'], self::PRODUCT_CHUNK );
					foreach ( $slice as $product ) {
						$this->installProduct( $pkg, $product, $job );
						$job['done_items']++;
					}
					$job['offset'] += count( $slice );
					if ( $job['offset'] >= count( (array) $pkg['products'] ) ) {
						$job['phase'] = 'groups';
					}
					break;

				case 'groups':
					$this->installGroups( $pkg, $job );
					$job['phase']        = 'theme';
					$job['theme_offset'] = 0;
					$job['theme_total']  = count( $this->themeMediaKeys( $pkg ) );
					break;

				case 'theme':
					// Slice 1..n: pre-fetch the banner images. Final slice: write the options.
					$keys = $this->themeMediaKeys( $pkg );
					$off  = (int) ( $job['theme_offset'] ?? 0 );
					if ( $off < count( $keys ) ) {
						foreach ( array_slice( $keys, $off, self::THEME_MEDIA_CHUNK ) as $key ) {
							$this->media( $pkg, $job, $key );
						}
						$off = min( count( $keys ), $off + self::THEME_MEDIA_CHUNK );
						$job['theme_offset'] = $off;
					}
					if ( $off >= count( $keys ) ) {
						$this->installThemeOptions( $pkg, $job );
						$job['phase'] = 'sample';
					}
					break;

				case 'sample':
					$this->generateSampleData( (string) $job['preset'] );
					$job['phase'] = 'finish';
					break;

				case 'finish':
					update_option( self::OPT_INSTALLED, [
						'preset'       => $job['preset'],
						'installed_at' => gmdate( 'c' ),
						'package_url'  => $job['package_url'],
					], false );
					delete_option( self::OPT_LOCK_NOTICE );
					$job['status'] = 'done';
					$this->purgeCache(); // Package JSON is only needed while a job is running.
					break;

				default:
					throw new \RuntimeException( 'Unknown job phase: ' . $job['phase'] );
			}
		} catch ( \Throwable $e ) {
			$job['status'] = 'error';
			$job['error']  = $e->getMessage();
			titancart_log( '[TitanCart] Demo import error (' . ( $job['phase'] ?? '?' ) . '): ' . $e->getMessage() );
		}

		$job['touched_at'] = time();
		update_option( self::OPT_JOB, $job, false );
		return $this->decorate( $job );
	}

	private function decorate( array $job ): array {
		$phase_idx = array_search( $job['phase'] ?? '', self::PHASES, true );
		$phase_idx = false === $phase_idx ? 0 : (int) $phase_idx;
		$per_phase = 100 / count( self::PHASES );
		$pct       = $phase_idx * $per_phase;
		if ( 'products' === ( $job['phase'] ?? '' ) && ! empty( $job['total'] ) ) {
			$pct += $per_phase * min( 1, (int) $job['offset'] / (int) $job['total'] );
		} elseif ( 'theme' === ( $job['phase'] ?? '' ) && ! empty( $job['theme_total'] ) ) {
			$pct += $per_phase * min( 1, (int) ( $job['theme_offset'] ?? 0 ) / (int) $job['theme_total'] );
		}
		$theme_label = ! empty( $job['theme_total'] )
			? sprintf( 'Applying theme design (%d of %d images)…', min( (int) ( $job['theme_offset'] ?? 0 ), (int) $job['theme_total'] ), (int) $job['theme_total'] )
			: 'Applying theme design…';
		$labels = [
			'remove'     => 'Removing the current demo…',
			'brands'     => 'Creating brands…',
			'categories' => 'Building categories…',
			'features'   => 'Creating attributes…',
			'products'   => sprintf( 'Importing products (%d of %d)…', (int) ( $job['offset'] ?? 0 ), (int) ( $job['total'] ?? 0 ) ),
			'groups'     => 'Linking variations…',
			'theme'      => $theme_label,
			'sample'     => 'Generating sample orders and reviews…',
			'finish'     => 'Finishing up…',
		];
		$job['percent'] = ( 'done' === ( $job['status'] ?? '' ) ) ? 100 : (int) round( $pct );
		$job['message'] = 'error' === ( $job['status'] ?? '' ) ? (string) $job['error'] : ( 'done' === ( $job['status'] ?? '' ) ? 'Demo installed.' : ( $labels[ $job['phase'] ?? '' ] ?? '' ) );
		unset( $job['package'] );
		return $job;
	}

	// ══════════════════════════════════════════════════════════════════════════
	// Remove / clear
	// ══════════════════════════════════════════════════════════════════════════

	/**
	 * Delete every logged row of a preset (or only the given entity types).
	 *
	 * @return array<string,int>  entity_type → rows removed
	 */
	public function remove( string $preset, ?array $only_types = null ): array {
		$types   = $only_types ?? array_merge( self::LAYER_SAMPLE, self::LAYER_CATALOG );
		$removed = [];

		foreach ( $types as $type ) {
			$rows = $this->db->getArray( "SELECT id, source_key, local_id FROM {$this->log} WHERE preset = ?s AND entity_type = ?s", $preset, $type );
			$n = 0;
			foreach ( $rows as $r ) {
				if ( $this->deleteEntity( $type, (int) $r['local_id'], (string) $r['source_key'], $preset ) ) {
					$n++;
				}
			}
			$this->db->query( "DELETE FROM {$this->log} WHERE preset = ?s AND entity_type = ?s", $preset, $type );
			$removed[ $type ] = $n;
		}

		if ( null === $only_types ) {
			( new CategoryRepository() )->rebuild_nested_set();
			$installed = get_option( self::OPT_INSTALLED, null );
			if ( is_array( $installed ) && ( $installed['preset'] ?? '' ) === $preset ) {
				delete_option( self::OPT_INSTALLED );
			}
		}
		$this->recomputeAggregates();
		SettingsService::flush( 'theme_options' );
		return $removed;
	}

	/** "Clear demo sample data" — transactional layer only. */
	public function clearSampleData(): array {
		$installed = get_option( self::OPT_INSTALLED, null );
		if ( ! is_array( $installed ) ) {
			return [];
		}
		return $this->remove( (string) $installed['preset'], self::LAYER_SAMPLE );
	}

	private function deleteEntity( string $type, int $id, string $key, string $preset ): bool {
		$t = fn( string $n ) => $this->db->table( $n );
		switch ( $type ) {
			case 'product':
				foreach ( [ 'product_translations', 'product_categories', 'product_tags', 'product_images', 'product_prices', 'product_feature_values', 'product_option_exceptions', 'product_group_products', 'product_variation_links' ] as $tbl ) {
					$col = ( 'product_variation_links' === $tbl ) ? 'parent_id' : 'product_id';
					$this->db->query( "DELETE FROM {$t($tbl)} WHERE {$col} = ?i", $id );
				}
				foreach ( $this->db->getColumn( "SELECT id FROM {$t('product_options')} WHERE product_id = ?i", $id ) as $oid ) {
					$this->deleteOption( (int) $oid );
				}
				$this->db->query( "DELETE FROM {$t('products')} WHERE id = ?i", $id );
				return true;
			case 'category':
				$this->db->query( "DELETE FROM {$t('category_translations')} WHERE category_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('product_feature_categories')} WHERE category_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('categories')} WHERE id = ?i", $id );
				return true;
			case 'brand':
				$this->db->query( "DELETE FROM {$t('brand_translations')} WHERE brand_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('brands')} WHERE id = ?i", $id );
				return true;
			case 'feature':
				$this->db->query( "DELETE FROM {$t('product_feature_translations')} WHERE feature_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('product_feature_categories')} WHERE feature_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('product_feature_values')} WHERE feature_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('product_features')} WHERE id = ?i", $id );
				return true;
			case 'feature_variant':
				$this->db->query( "DELETE FROM {$t('product_feature_variant_translations')} WHERE variant_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('product_feature_variants')} WHERE id = ?i", $id );
				return true;
			case 'product_group':
				$this->db->query( "DELETE FROM {$t('product_group_products')} WHERE group_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('product_groups')} WHERE id = ?i", $id );
				return true;
			case 'tag':
				$this->db->query( "DELETE FROM {$t('product_tags')} WHERE tag_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('tags')} WHERE id = ?i", $id );
				return true;
			case 'attachment':
				return (bool) wp_delete_attachment( $id, true );
			case 'theme_option':
				$this->db->query( "DELETE FROM {$t('settings')} WHERE section = 'theme_options' AND `key` = ?s", $preset . '_' . $key );
				return true;
			case 'review':
				$this->db->query( "DELETE FROM {$t('review_votes')} WHERE review_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('reviews')} WHERE id = ?i", $id );
				return true;
			case 'order_item':
				$this->db->query( "DELETE FROM {$t('order_item_options')} WHERE order_item_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('order_items')} WHERE id = ?i", $id );
				return true;
			case 'order':
				foreach ( [ 'order_notes', 'order_history' ] as $tbl ) {
					$this->db->query( "DELETE FROM {$t($tbl)} WHERE order_id = ?i", $id );
				}
				$this->db->query( "DELETE FROM {$t('orders')} WHERE id = ?i", $id );
				return true;
			case 'customer':
				$this->db->query( "DELETE FROM {$t('customer_addresses')} WHERE customer_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('customer_usergroups')} WHERE customer_id = ?i", $id );
				$this->db->query( "DELETE FROM {$t('customers')} WHERE id = ?i", $id );
				return true;
		}
		return false;
	}

	private function deleteOption( int $option_id ): void {
		$t = fn( string $n ) => $this->db->table( $n );
		foreach ( $this->db->getColumn( "SELECT id FROM {$t('product_option_variants')} WHERE option_id = ?i", $option_id ) as $vid ) {
			$this->db->query( "DELETE FROM {$t('product_option_variant_translations')} WHERE variant_id = ?i", (int) $vid );
		}
		$this->db->query( "DELETE FROM {$t('product_option_variants')} WHERE option_id = ?i", $option_id );
		$this->db->query( "DELETE FROM {$t('product_option_translations')} WHERE option_id = ?i", $option_id );
		$this->db->query( "DELETE FROM {$t('product_option_dependencies')} WHERE option_id = ?i OR parent_option_id = ?i", $option_id, $option_id );
		$this->db->query( "DELETE FROM {$t('product_options')} WHERE id = ?i", $option_id );
	}

	// ══════════════════════════════════════════════════════════════════════════
	// Install phases (catalog / design layer)
	// ══════════════════════════════════════════════════════════════════════════

	private function installBrands( array $pkg, array &$job ): void {
		$preset = (string) $job['preset'];
		foreach ( (array) ( $pkg['brands'] ?? [] ) as $b ) {
			$id   = $this->local( $preset, 'brand', $b['key'] );
			$data = [
				'slug'     => (string) $b['slug'],
				'image_id' => $this->media( $pkg, $job, $b['image'] ?? null ),
				'position' => (int) ( $b['position'] ?? 0 ),
				'status'   => (string) ( $b['status'] ?? 'active' ),
			];
			$id = $this->upsertRow( 'brands', $id, $data );
			$this->writeTranslations( 'brand_translations', 'brand_id', $id, (array) ( $b['translations'] ?? [] ), [ 'name', 'description' ] );
			$this->logRow( $preset, 'brand', $b['key'], $id );
		}
	}

	private function installCategories( array $pkg, array &$job ): void {
		$preset = (string) $job['preset'];
		$slug_to_id = [];
		// Package order is lft-ascending: parents always precede children.
		foreach ( (array) ( $pkg['categories'] ?? [] ) as $c ) {
			$parent = ( ! empty( $c['parent'] ) && isset( $slug_to_id[ $c['parent'] ] ) ) ? $slug_to_id[ $c['parent'] ] : 0;
			$id     = $this->local( $preset, 'category', $c['key'] );
			$data   = [
				'parent_id'      => $parent,
				'slug'           => $this->uniqueSlug( 'categories', (string) $c['slug'], $id ),
				'image_id'       => $this->media( $pkg, $job, $c['image'] ?? null ),
				'position'       => (int) ( $c['position'] ?? 0 ),
				'status'         => (string) ( $c['status'] ?? 'active' ),
				'age_restricted' => (int) ( $c['age_restricted'] ?? 0 ),
				'age_limit'      => (int) ( $c['age_limit'] ?? 0 ),
			];
			$id = $this->upsertRow( 'categories', $id, $data );
			$this->writeTranslations( 'category_translations', 'category_id', $id, (array) ( $c['translations'] ?? [] ), [ 'name', 'description', 'meta_title', 'meta_desc' ] );
			$slug_to_id[ $c['key'] ] = $id;
			$this->logRow( $preset, 'category', $c['key'], $id ); // fingerprint stamped after the tree rebuild below
		}
		( new CategoryRepository() )->rebuild_nested_set();
		foreach ( $slug_to_id as $key => $id ) {
			$this->stampFingerprint( $preset, 'category', $key, $id );
		}
	}

	private function installFeatures( array $pkg, array &$job ): void {
		$preset = (string) $job['preset'];
		$key_to_id = [];
		foreach ( (array) ( $pkg['features'] ?? [] ) as $f ) {
			$parent = ( ! empty( $f['parent'] ) && isset( $key_to_id[ $f['parent'] ] ) ) ? $key_to_id[ $f['parent'] ] : 0;
			$id     = $this->local( $preset, 'feature', $f['key'] );
			$data   = [
				'parent_id'    => $parent,
				'type'         => (string) ( $f['type'] ?? 'T' ),
				'filter_type'  => (string) ( $f['filter_type'] ?? 'checkbox' ),
				'purpose'      => (string) ( $f['purpose'] ?? 'filter' ),
				'feature_code' => (string) ( $f['feature_code'] ?? '' ),
				'position'     => (int) ( $f['position'] ?? 0 ),
				'filterable'   => (int) ( $f['filterable'] ?? 0 ),
				'compareable'  => (int) ( $f['compareable'] ?? 0 ),
				'show_on_pdp'  => (int) ( $f['show_on_pdp'] ?? 1 ),
				'sort_mode'    => (string) ( $f['sort_mode'] ?? 'manual' ),
				'status'       => (string) ( $f['status'] ?? 'active' ),
			];
			$id = $this->upsertRow( 'product_features', $id, $data );
			$this->writeTranslations( 'product_feature_translations', 'feature_id', $id, (array) ( $f['translations'] ?? [] ), [ 'name', 'description' ] );
			$key_to_id[ $f['key'] ] = $id;
			$this->logRow( $preset, 'feature', $f['key'], $id );

			foreach ( (array) ( $f['variants'] ?? [] ) as $v ) {
				$vid = $this->local( $preset, 'feature_variant', $v['key'] );
				$vid = $this->upsertRow( 'product_feature_variants', $vid, [
					'feature_id' => $id,
					'color_hex'  => $v['color_hex'] ?? null,
					'position'   => (int) ( $v['position'] ?? 0 ),
					'status'     => (string) ( $v['status'] ?? 'active' ),
				] );
				$this->writeTranslations( 'product_feature_variant_translations', 'variant_id', $vid, (array) ( $v['translations'] ?? [] ), [ 'name' ] );
				$this->logRow( $preset, 'feature_variant', $v['key'], $vid );
			}

			$this->db->query( "DELETE FROM {$this->db->table('product_feature_categories')} WHERE feature_id = ?i", $id );
			foreach ( (array) ( $f['categories'] ?? [] ) as $cslug ) {
				$cid = $this->local( $preset, 'category', $cslug );
				if ( $cid ) {
					$this->db->insert( 'product_feature_categories', [ 'feature_id' => $id, 'category_id' => $cid ] );
				}
			}
		}
	}

	private function installProduct( array $pkg, array $pr, array &$job ): void {
		$preset = (string) $job['preset'];
		$id     = $this->local( $preset, 'product', $pr['key'] );

		$skip = [ 'key', 'brand', 'translations', 'categories', 'tags', 'images', 'prices', 'feature_values', 'options' ];
		$data = [];
		foreach ( $pr as $col => $val ) {
			if ( ! in_array( $col, $skip, true ) ) {
				$data[ $col ] = $val;
			}
		}
		$data['brand_id'] = ! empty( $pr['brand'] ) ? ( $this->local( $preset, 'brand', $pr['brand'] ) ?: null ) : null;
		$data['slug']     = $this->uniqueSlug( 'products', (string) ( $pr['slug'] ?? '' ), $id );
		$data['sku']      = $this->uniqueSku( (string) ( $pr['sku'] ?? '' ), $id );
		$id = $this->upsertRow( 'products', $id, $data );

		$this->writeTranslations( 'product_translations', 'product_id', $id, (array) ( $pr['translations'] ?? [] ), [ 'name', 'description', 'short_desc', 'meta_title', 'meta_desc' ] );

		$t = fn( string $n ) => $this->db->table( $n );

		// Categories
		$this->db->query( "DELETE FROM {$t('product_categories')} WHERE product_id = ?i", $id );
		foreach ( (array) ( $pr['categories'] ?? [] ) as $c ) {
			$cid = $this->local( $preset, 'category', $c['category'] );
			if ( $cid ) {
				$this->db->insert( 'product_categories', [ 'product_id' => $id, 'category_id' => $cid, 'is_main' => (int) ( $c['is_main'] ?? 0 ), 'position' => (int) ( $c['position'] ?? 0 ) ] );
			}
		}

		// Tags (find-or-create, logged so removal only drops tags the demo created)
		$this->db->query( "DELETE FROM {$t('product_tags')} WHERE product_id = ?i", $id );
		foreach ( (array) ( $pr['tags'] ?? [] ) as $name ) {
			$slug = sanitize_title( (string) $name );
			if ( '' === $slug ) {
				continue;
			}
			$tid = (int) $this->db->getField( "SELECT id FROM {$t('tags')} WHERE slug = ?s", $slug );
			if ( ! $tid ) {
				$tid = $this->db->insert( 'tags', [ 'name' => (string) $name, 'slug' => $slug ] );
				$this->logRow( $preset, 'tag', $slug, $tid );
			}
			$linked = (int) $this->db->getField( "SELECT COUNT(*) FROM {$t('product_tags')} WHERE product_id = ?i AND tag_id = ?i", $id, $tid );
			if ( ! $linked ) {
				$this->db->insert( 'product_tags', [ 'product_id' => $id, 'tag_id' => $tid ] );
			}
		}

		// Images
		$this->db->query( "DELETE FROM {$t('product_images')} WHERE product_id = ?i", $id );
		foreach ( (array) ( $pr['images'] ?? [] ) as $img ) {
			$att = $this->media( $pkg, $job, $img['media'] ?? null );
			if ( $att ) {
				$this->db->insert( 'product_images', [ 'product_id' => $id, 'wp_attachment_id' => $att, 'position' => (int) ( $img['position'] ?? 0 ), 'is_main' => (int) ( $img['is_main'] ?? 0 ), 'alt_text' => (string) ( $img['alt_text'] ?? '' ) ] );
			}
		}

		// Prices
		$this->db->query( "DELETE FROM {$t('product_prices')} WHERE product_id = ?i", $id );
		foreach ( (array) ( $pr['prices'] ?? [] ) as $p ) {
			$this->db->insert( 'product_prices', [ 'product_id' => $id, 'usergroup_id' => (int) ( $p['usergroup_id'] ?? 0 ), 'min_quantity' => (int) ( $p['min_quantity'] ?? 1 ), 'price' => $p['price'] ?? 0, 'discount_type' => (string) ( $p['discount_type'] ?? 'absolute' ), 'discount_value' => $p['discount_value'] ?? 0 ] );
		}

		// Attribute values
		$this->db->query( "DELETE FROM {$t('product_feature_values')} WHERE product_id = ?i", $id );
		foreach ( (array) ( $pr['feature_values'] ?? [] ) as $fv ) {
			$fid = $this->local( $preset, 'feature', $fv['feature'] );
			if ( ! $fid ) {
				continue;
			}
			$vid = ! empty( $fv['variant'] ) ? $this->local( $preset, 'feature_variant', $fv['variant'] ) : 0;
			$this->db->insert( 'product_feature_values', [
				'product_id'  => $id,
				'feature_id'  => $fid,
				'value_int'   => $fv['value_int'] ?? null,
				'value_float' => $fv['value_float'] ?? null,
				'value_str'   => $fv['value_str'] ?? null,
				'value_text'  => $fv['value_text'] ?? null,
				'value_date'  => $fv['value_date'] ?? null,
				'variant_id'  => $vid ?: null,
				'image_id'    => $this->media( $pkg, $job, $fv['image'] ?? null ),
			] );
		}

		// Options (add-on services): rebuilt wholesale on repair
		foreach ( $this->db->getColumn( "SELECT id FROM {$t('product_options')} WHERE product_id = ?i", $id ) as $oid ) {
			$this->deleteOption( (int) $oid );
		}
		$this->db->query( "DELETE FROM {$t('product_option_exceptions')} WHERE product_id = ?i", $id );
		$opt_ids = [];
		$var_ids = [];
		$exceptions = [];
		foreach ( (array) ( $pr['options'] ?? [] ) as $o ) {
			$oid = $this->db->insert( 'product_options', [
				'product_id'       => $id,
				'type'             => (string) ( $o['type'] ?? 'S' ),
				'position'         => (int) ( $o['position'] ?? 0 ),
				'required'         => (int) ( $o['required'] ?? 0 ),
				'compound_pricing' => (int) ( $o['compound_pricing'] ?? 0 ),
				'status'           => (string) ( $o['status'] ?? 'active' ),
			] );
			$opt_ids[ $o['key'] ] = $oid;
			$this->writeTranslations( 'product_option_translations', 'option_id', $oid, (array) ( $o['translations'] ?? [] ), [ 'name', 'comment' ] );
			foreach ( (array) ( $o['variants'] ?? [] ) as $v ) {
				$vid = $this->db->insert( 'product_option_variants', [
					'option_id'       => $oid,
					'position'        => (int) ( $v['position'] ?? 0 ),
					'modifier'        => $v['modifier'] ?? 0,
					'modifier_type'   => (string) ( $v['modifier_type'] ?? 'A' ),
					'weight_modifier' => $v['weight_modifier'] ?? 0,
					'image_id'        => $this->media( $pkg, $job, $v['image'] ?? null ),
					'status'          => (string) ( $v['status'] ?? 'active' ),
				] );
				$var_ids[ $v['key'] ] = $vid;
				$this->writeTranslations( 'product_option_variant_translations', 'variant_id', $vid, (array) ( $v['translations'] ?? [] ), [ 'name' ] );
			}
			if ( ! empty( $o['_product_exceptions'] ) ) {
				$exceptions = (array) $o['_product_exceptions'];
			}
		}
		foreach ( (array) ( $pr['options'] ?? [] ) as $o ) {
			foreach ( (array) ( $o['dependencies'] ?? [] ) as $d ) {
				if ( isset( $opt_ids[ $o['key'] ], $opt_ids[ $d['parent_option'] ], $var_ids[ $d['parent_variant'] ] ) ) {
					$this->db->insert( 'product_option_dependencies', [ 'option_id' => $opt_ids[ $o['key'] ], 'parent_option_id' => $opt_ids[ $d['parent_option'] ], 'parent_variant_id' => $var_ids[ $d['parent_variant'] ] ] );
				}
			}
		}
		foreach ( $exceptions as $e ) {
			$combo = [];
			foreach ( (array) ( $e['combination'] ?? [] ) as $ok => $vk ) {
				if ( isset( $opt_ids[ $ok ], $var_ids[ $vk ] ) ) {
					$combo[ $opt_ids[ $ok ] ] = $var_ids[ $vk ];
				}
			}
			if ( $combo ) {
				$this->db->insert( 'product_option_exceptions', [ 'product_id' => $id, 'combination' => wp_json_encode( $combo ), 'type' => (string) ( $e['type'] ?? 'forbidden' ) ] );
			}
		}

		$this->logRow( $preset, 'product', $pr['key'], $id );
		$this->stampFingerprint( $preset, 'product', $pr['key'], $id );
	}

	private function installGroups( array $pkg, array &$job ): void {
		$preset = (string) $job['preset'];
		$t = fn( string $n ) => $this->db->table( $n );
		foreach ( (array) ( $pkg['groups'] ?? [] ) as $g ) {
			$fids = [];
			foreach ( (array) ( $g['features'] ?? [] ) as $fk ) {
				$fid = $this->local( $preset, 'feature', $fk );
				if ( $fid ) {
					$fids[] = $fid;
				}
			}
			$gid = $this->local( $preset, 'product_group', $g['key'] );
			if ( ! $gid ) {
				// A real group with this code may already exist — adopt it rather than fail on uq_group_code.
				$gid = (int) $this->db->getField( "SELECT id FROM {$t('product_groups')} WHERE group_code = ?s", (string) $g['group_code'] );
			}
			$gid = $this->upsertRow( 'product_groups', $gid, [
				'group_code'        => (string) $g['group_code'],
				'feature_ids'       => wp_json_encode( $fids ),
				'display_as_single' => (int) ( $g['display_as_single'] ?? 0 ),
				'display_name'      => (string) ( $g['display_name'] ?? '' ),
			] );
			$this->db->query( "DELETE FROM {$t('product_group_products')} WHERE group_id = ?i", $gid );
			foreach ( (array) ( $g['members'] ?? [] ) as $m ) {
				$pid = $this->local( $preset, 'product', $m['product'] );
				if ( ! $pid ) {
					continue;
				}
				$combo = [];
				foreach ( (array) ( $m['combination'] ?? [] ) as $fk => $vk ) {
					$fid = $this->local( $preset, 'feature', $fk );
					$vid = $this->local( $preset, 'feature_variant', $vk );
					if ( $fid && $vid ) {
						$combo[ $fid ] = $vid;
					}
				}
				$this->db->insert( 'product_group_products', [ 'group_id' => $gid, 'product_id' => $pid, 'combination' => wp_json_encode( (object) $combo ), 'position' => (int) ( $m['position'] ?? 0 ) ] );
			}
			$this->logRow( $preset, 'product_group', $g['key'], $gid );
		}
	}

	private function installThemeOptions( array $pkg, array &$job ): void {
		$preset = (string) $job['preset'];
		$t      = $this->db->table( 'settings' );
		$now    = gmdate( 'Y-m-d H:i:s' );

		$write = function ( string $key, string $value ) use ( $t, $now ) {
			$this->db->query(
				"INSERT INTO {$t} (section, `key`, value, autoload, updated_at) VALUES ('theme_options', ?s, ?s, 1, ?s)
				 ON DUPLICATE KEY UPDATE value = ?s, updated_at = ?s",
				$key, $value, $now, $value, $now
			);
		};

		foreach ( (array) ( $pkg['theme_options'] ?? [] ) as $bare => $value ) {
			$value = (string) $value;
			if ( in_array( $bare, DemoExportService::CATEGORY_LIST_SUFFIXES, true ) ) {
				$ids = [];
				foreach ( array_filter( explode( ',', $value ) ) as $slug ) {
					$cid = $this->local( $preset, 'category', trim( $slug ) );
					if ( $cid ) {
						$ids[] = $cid;
					}
				}
				$value = implode( ',', $ids );
			} elseif ( preg_match( '/^\{\{media:([^}]+)\}\}$/', $value, $m ) ) {
				$att   = $this->media( $pkg, $job, $m[1] );
				$value = $att ? (string) wp_get_attachment_url( $att ) : '';
			}
			$write( $preset . '_' . $bare, $value );
			$this->logRow( $preset, 'theme_option', (string) $bare, 0 );
		}
		// Activate the preset and turn its demo scope on so header/home tiles show.
		$prev = SettingsService::get( 'theme_options', 'active_preset', '' );
		$write( 'active_preset', $preset );
		$write( $preset . '_cat_scope_enabled', '1' );
		// S473: log the scope toggle as a demo row (NO fingerprint) so remove() /
		// switch delete it with the rest of `<preset>_*` — it survived the
		// Electronics → Firearms switch on the fresh Playground pass. Unfingerprinted
		// = the user may flip it without tripping "demo item(s) were edited".
		$this->logRow( $preset, 'theme_option', 'cat_scope_enabled', 0 );
		SettingsService::flush( 'theme_options' );

		// Same side-effects the Theme Options PUT applies on a preset CHANGE
		// (SettingsController::update_section): pin the product image shape the
		// preset was designed for, and couple the marketplace toggle to the preset.
		if ( $prev !== $preset ) {
			// S473: log the flip the same way the Theme Options PUT does. A demo
			// install silently re-pointing the storefront was invisible in System
			// Logs (only "Demo install started" was written) — Sean found demoxpert
			// on Marketplace a day later with no record of why.
			\TitanCart\Core\LogService::info( 'general', 'update', 'Active preset changed to "' . $preset . '" by demo install' . ( $prev !== '' ? ' (was "' . $prev . '")' : '' ), [
				'object_type' => 'demo_import',
				'preset'      => $preset,
				'previous'    => (string) $prev,
			] );
			$shape = (string) apply_filters( 'titancart_preset_image_shape', '', $preset );
			if ( in_array( $shape, [ 'tall', 'square', 'wide' ], true ) ) {
				$this->db->query(
					"INSERT INTO {$t} (section, `key`, value, autoload, updated_at) VALUES ('thumbnails', 'product_image_shape', ?s, 1, ?s)
					 ON DUPLICATE KEY UPDATE value = ?s, updated_at = ?s",
					$shape, $now, $shape, $now
				);
				SettingsService::flush( 'thumbnails' );
			}
			if ( class_exists( '\\TitanCartMarketplace\\MarketplacePlugin' ) ) {
				$mp = ( 'marketplace' === $preset ) ? '1' : '0';
				$this->db->query(
					"INSERT INTO {$t} (section, `key`, value, autoload, updated_at) VALUES ('marketplace', 'enabled', ?s, 1, ?s)
					 ON DUPLICATE KEY UPDATE value = ?s, updated_at = ?s",
					$mp, $now, $mp, $now
				);
				SettingsService::flush( 'marketplace' );
			}
		}

		foreach ( (array) ( $pkg['theme_options'] ?? [] ) as $bare => $_ ) {
			$this->stampFingerprint( $preset, 'theme_option', (string) $bare, 0 );
		}
	}

	// ══════════════════════════════════════════════════════════════════════════
	// Sample layer (generated, never exported)
	// ══════════════════════════════════════════════════════════════════════════

	/**
	 * Deterministic per preset (seeded RNG) so a repair regenerates the same
	 * set; dates are relative to install time so the dashboard stays current.
	 */
	public function generateSampleData( string $preset ): void {
		$t = fn( string $n ) => $this->db->table( $n );
		$products = $this->db->getArray(
			"SELECT p.id, p.sku, p.price, tr.name FROM {$t('products')} p
			   JOIN {$this->log} l ON l.entity_type = 'product' AND l.local_id = p.id AND l.preset = ?s
			   LEFT JOIN {$t('product_translations')} tr ON tr.product_id = p.id AND tr.language_code = 'en'
			  WHERE p.deleted_at IS NULL AND p.status = 'active' AND p.price > 0
			  ORDER BY p.id ASC",
			$preset
		);
		if ( empty( $products ) ) {
			return;
		}

		mt_srand( crc32( 'titancart-demo:' . $preset ) );
		$pick = fn( array $a ) => $a[ mt_rand( 0, count( $a ) - 1 ) ];

		$first = [ 'Ava', 'Liam', 'Mia', 'Noah', 'Zoe', 'Ethan', 'Ivy', 'Lucas', 'Nora', 'Owen', 'Ruby', 'Leo', 'Sofia', 'Eli', 'Grace', 'Max', 'Chloe', 'Jack', 'Lily', 'Henry', 'Emma', 'Caleb', 'Aria', 'Miles' ];
		$last  = [ 'Bennett', 'Carter', 'Diaz', 'Foster', 'Hayes', 'Jensen', 'Kim', 'Lopez', 'Morgan', 'Nguyen', 'Parker', 'Reyes', 'Shaw', 'Turner', 'Walsh', 'Young', 'Brooks', 'Cole', 'Ellis', 'Gray' ];
		$cities = [ [ 'Austin', 'TX', '78701' ], [ 'Denver', 'CO', '80202' ], [ 'Portland', 'OR', '97201' ], [ 'Raleigh', 'NC', '27601' ], [ 'Madison', 'WI', '53703' ], [ 'Tampa', 'FL', '33602' ], [ 'Boise', 'ID', '83702' ], [ 'Pittsburgh', 'PA', '15222' ], [ 'Tucson', 'AZ', '85701' ], [ 'Burlington', 'VT', '05401' ] ];
		$streets = [ 'Maple Ave', 'Oak St', 'Cedar Ln', 'Ridge Rd', 'Harbor Dr', 'Willow Ct', 'Summit Way', 'Lakeview Blvd' ];

		// Customers
		$customers = [];
		for ( $i = 1; $i <= self::SAMPLE_CUSTOMERS; $i++ ) {
			$fn = $pick( $first );
			$ln = $pick( $last );
			$city = $pick( $cities );
			$street = mt_rand( 100, 9800 ) . ' ' . $pick( $streets );
			$email  = strtolower( $fn . '.' . $ln . $i ) . '@example.com';
			$key  = 'customer:' . $i;
			$cid  = $this->local( $preset, 'customer', $key );
			$days_ago = mt_rand( self::SAMPLE_DAYS, self::SAMPLE_DAYS * 6 );
			$cid = $this->upsertRow( 'customers', $cid, [
				'email'      => $email,
				'first_name' => $fn,
				'last_name'  => $ln,
				'phone'      => sprintf( '555-01%02d', $i ),
				'status'     => 'active',
				'created_at' => gmdate( 'Y-m-d H:i:s', time() - $days_ago * DAY_IN_SECONDS ),
			] );
			$this->db->query( "DELETE FROM {$t('customer_addresses')} WHERE customer_id = ?i", $cid );
			foreach ( [ 'billing', 'shipping' ] as $type ) {
				$this->db->insert( 'customer_addresses', [ 'customer_id' => $cid, 'type' => $type, 'is_default' => 1, 'first_name' => $fn, 'last_name' => $ln, 'address_1' => $street, 'city' => $city[0], 'state' => $city[1], 'postcode' => $city[2], 'country' => 'US' ] );
			}
			$this->logRow( $preset, 'customer', $key, $cid );
			$customers[] = [ 'id' => $cid, 'fn' => $fn, 'ln' => $ln, 'email' => $email, 'city' => $city, 'street' => $street ];
		}

		// Status ids (installed handles).
		$status_id = [];
		foreach ( $this->db->getArray( "SELECT id, handle FROM {$t('order_statuses')} WHERE type = 'order'" ) as $s ) {
			$status_id[ $s['handle'] ] = (int) $s['id'];
		}
		$weights = [ 'completed' => 68, 'processing' => 14, 'pending' => 6, 'on_hold' => 4, 'cancelled' => 4, 'refunded' => 4 ];
		$pick_status = function () use ( $weights, $status_id ): string {
			$r = mt_rand( 1, 100 );
			$acc = 0;
			foreach ( $weights as $h => $w ) {
				$acc += $w;
				if ( $r <= $acc && isset( $status_id[ $h ] ) ) {
					return $h;
				}
			}
			return isset( $status_id['completed'] ) ? 'completed' : (string) array_key_first( $status_id );
		};

		// Orders — denser toward today (quadratic skew).
		$prefix_raw = SettingsService::get( 'checkout', 'order_number_prefix', 'TC' );
		$prefix     = trim( strtoupper( (string) preg_replace( '/[^A-Za-z0-9\-]/', '', $prefix_raw ) ), '-' );
		$prefix     = ( '' === $prefix ? '' : substr( $prefix, 0, 12 ) . '-' );

		$totals = []; // customer id → [orders, spent, last]
		$sales  = []; // product id → qty
		$order_ids = [];
		for ( $n = 1; $n <= self::SAMPLE_ORDERS; $n++ ) {
			$cust  = $pick( $customers );
			$u     = mt_rand( 0, 1000 ) / 1000;
			$days  = (int) floor( self::SAMPLE_DAYS * $u * $u );
			$ts    = time() - $days * DAY_IN_SECONDS - mt_rand( 0, DAY_IN_SECONDS - 1 );
			$when  = gmdate( 'Y-m-d H:i:s', $ts );
			$handle = $pick_status();
			$key   = 'order:' . $n;
			$oid   = $this->local( $preset, 'order', $key );

			$item_count = mt_rand( 1, 3 );
			$subtotal   = 0.0;
			$items      = [];
			for ( $k = 0; $k < $item_count; $k++ ) {
				$prod = $pick( $products );
				$qty  = mt_rand( 1, 2 );
				$line = round( (float) $prod['price'] * $qty, 4 );
				$subtotal += $line;
				$items[] = [ 'product' => $prod, 'qty' => $qty, 'line' => $line ];
			}
			$shipping = $subtotal >= 75 ? 0.0 : 8.95;
			$tax      = round( $subtotal * 0.07, 4 );
			$total    = round( $subtotal + $shipping + $tax, 4 );

			$number = $prefix . gmdate( 'Ymd', $ts ) . '-D' . str_pad( (string) $n, 3, '0', STR_PAD_LEFT );
			$oid = $this->upsertRow( 'orders', $oid, [
				'order_number'        => $number,
				'order_key'           => substr( sha1( $preset . $key ), 0, 32 ),
				'customer_id'         => $cust['id'],
				'status_id'           => $status_id[ $handle ] ?? 0,
				'currency'            => 'USD',
				'subtotal'            => $subtotal,
				'shipping_cost'       => $shipping,
				'tax_total'           => $tax,
				'total'               => $total,
				'stock_state'         => 'none',
				'payment_info'        => wp_json_encode( [ 'method' => 'Demo card', 'last4' => sprintf( '%04d', mt_rand( 0, 9999 ) ) ] ),
				'shipping_info'       => wp_json_encode( [ 'method' => $shipping > 0 ? 'Standard shipping' : 'Free shipping' ] ),
				'billing_first_name'  => $cust['fn'],
				'billing_last_name'   => $cust['ln'],
				'billing_address_1'   => $cust['street'],
				'billing_city'        => $cust['city'][0],
				'billing_state'       => $cust['city'][1],
				'billing_postcode'    => $cust['city'][2],
				'billing_country'     => 'US',
				'billing_email'       => $cust['email'],
				'shipping_first_name' => $cust['fn'],
				'shipping_last_name'  => $cust['ln'],
				'shipping_address_1'  => $cust['street'],
				'shipping_city'       => $cust['city'][0],
				'shipping_state'      => $cust['city'][1],
				'shipping_postcode'   => $cust['city'][2],
				'shipping_country'    => 'US',
				'referral_source'     => $pick( [ 'direct', 'google', 'instagram', 'newsletter', 'facebook' ] ),
				'meta'                => wp_json_encode( [ 'demo' => true ] ),
				'created_at'          => $when,
			] );
			$this->logRow( $preset, 'order', $key, $oid );
			$order_ids[] = $oid;

			// Items: rebuilt wholesale (order_item log rows for this order are replaced).
			foreach ( $this->db->getColumn( "SELECT id FROM {$t('order_items')} WHERE order_id = ?i", $oid ) as $old ) {
				$this->deleteEntity( 'order_item', (int) $old, '', $preset );
			}
			$this->db->query( "DELETE FROM {$this->log} WHERE preset = ?s AND entity_type = 'order_item' AND source_key LIKE ?s", $preset, $key . ':%' );
			foreach ( $items as $i => $it ) {
				$iid = $this->db->insert( 'order_items', [
					'order_id'   => $oid,
					'product_id' => (int) $it['product']['id'],
					'sku'        => (string) $it['product']['sku'],
					'name'       => (string) $it['product']['name'],
					'quantity'   => $it['qty'],
					'unit_price' => $it['product']['price'],
					'subtotal'   => $it['line'],
					'tax'        => round( $it['line'] * 0.07, 4 ),
					'total'      => round( $it['line'] * 1.07, 4 ),
				] );
				$this->logRow( $preset, 'order_item', $key . ':' . $i, $iid );
				if ( in_array( $handle, [ 'completed', 'processing' ], true ) ) {
					$sales[ (int) $it['product']['id'] ] = ( $sales[ (int) $it['product']['id'] ] ?? 0 ) + $it['qty'];
				}
			}

			if ( ! in_array( $handle, [ 'cancelled', 'refunded', 'failed' ], true ) ) {
				$c = $totals[ $cust['id'] ] ?? [ 0, 0.0, '' ];
				$c[0]++;
				$c[1] += $total;
				$c[2]  = max( $c[2], $when );
				$totals[ $cust['id'] ] = $c;
			}
		}
		foreach ( $totals as $cid => [ $count, $spent, $last ] ) {
			$this->db->update( 'customers', [ 'total_orders' => $count, 'total_spent' => $spent, 'last_ordered_at' => $last ], [ 'id' => $cid ] );
		}
		foreach ( $sales as $pid => $qty ) {
			$this->db->update( 'products', [ 'sales_count' => $qty ], [ 'id' => $pid ] );
		}

		// Reviews
		$titles = [ 'Exactly as described', 'Great quality', 'Would buy again', 'Solid purchase', 'Love it', 'Better than expected', 'Fast shipping, nice product', 'Good value', 'Very happy', 'Does the job' ];
		$bodies = [
			'Arrived quickly and looks even better in person. Very pleased with the quality.',
			'Bought this as a gift and it was a hit. Packaging was neat and everything worked as expected.',
			'Good build and finish. Took a star off only because delivery took a couple of days longer than estimated.',
			'This is my second order from this store and the consistency is what keeps me coming back.',
			'Nice product for the price. Would recommend to anyone looking for something reliable.',
			'Matches the photos exactly. Customer service answered my question within the hour.',
			'Well made and easy to use. No complaints so far after a few weeks.',
			'Great addition. Sizing and details were accurate on the product page.',
		];
		for ( $n = 1; $n <= self::SAMPLE_REVIEWS; $n++ ) {
			$prod  = $pick( $products );
			$cust  = $pick( $customers );
			$key   = 'review:' . $n;
			$rid   = $this->local( $preset, 'review', $key );
			$rating = $pick( [ 5, 5, 5, 4, 4, 4, 5, 3, 5, 4 ] );
			$days  = mt_rand( 0, self::SAMPLE_DAYS * 3 );
			$rid = $this->upsertRow( 'reviews', $rid, [
				'product_id'  => (int) $prod['id'],
				'customer_id' => $cust['id'],
				'rating'      => $rating,
				'title'       => $pick( $titles ),
				'body'        => $pick( $bodies ),
				'status'      => 'approved',
				'helpful'     => mt_rand( 0, 12 ),
				'created_at'  => gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS - mt_rand( 0, DAY_IN_SECONDS - 1 ) ),
			] );
			$this->logRow( $preset, 'review', $key, $rid );
		}
		$this->recomputeAggregates();
	}

	/** Recompute product rating_avg/rating_count from approved reviews (all products). */
	private function recomputeAggregates(): void {
		$p = $this->db->table( 'products' );
		$r = $this->db->table( 'reviews' );
		$this->db->query( "UPDATE {$p} SET rating_avg = 0, rating_count = 0" );
		$rows = $this->db->getArray( "SELECT product_id, AVG(rating) AS a, COUNT(*) AS n FROM {$r} WHERE status = 'approved' GROUP BY product_id" );
		foreach ( $rows as $row ) {
			$this->db->update( 'products', [ 'rating_avg' => round( (float) $row['a'], 2 ), 'rating_count' => (int) $row['n'] ], [ 'id' => (int) $row['product_id'] ] );
		}
	}

	// ══════════════════════════════════════════════════════════════════════════
	// Media
	// ══════════════════════════════════════════════════════════════════════════

	/**
	 * Package media keys referenced by the preset's theme options (`{{media:KEY}}`
	 * values), unique and in option order — the work list for the chunked theme
	 * phase. Products' images are NOT included; those are fetched per product.
	 */
	private function themeMediaKeys( array $pkg ): array {
		$keys = [];
		foreach ( (array) ( $pkg['theme_options'] ?? [] ) as $value ) {
			if ( is_string( $value ) && preg_match( '/^\{\{media:([^}]+)\}\}$/', $value, $m ) && ! in_array( $m[1], $keys, true ) ) {
				$keys[] = $m[1];
			}
		}
		return $keys;
	}

	/**
	 * Drop every cached package JSON under uploads/titancart/demo-cache/ (S472).
	 * Called on job finish; the file is only read by step() while a job runs, and
	 * fetchPackage() rewrites it on the next install. The .htaccess stays.
	 */
	private function purgeCache(): void {
		$dir = $this->cacheDir();
		foreach ( (array) glob( $dir . '/*.json' ) as $file ) {
			if ( is_file( $file ) ) {
				wp_delete_file( $file );
			}
		}
	}

	/**
	 * Attachment id for a package media key: reuse the logged attachment if it
	 * still exists, otherwise sideload (package-relative URL first, exporter's
	 * original URL as fallback) through ImportService's single fetch path.
	 */
	private function media( array $pkg, array &$job, $key ): ?int {
		if ( empty( $key ) ) {
			return null;
		}
		$preset = (string) $job['preset'];
		$entry  = $pkg['media'][ $key ] ?? null;
		if ( ! is_array( $entry ) ) {
			return null;
		}
		$src_key = ! empty( $entry['file'] ) ? basename( (string) $entry['file'] ) : (string) $key;

		$existing = $this->local( $preset, 'attachment', $src_key );
		if ( $existing && get_post( $existing ) ) {
			return $existing;
		}

		$urls = [];
		if ( ! empty( $entry['file'] ) ) {
			$urls[] = (string) $job['base_url'] . ltrim( (string) $entry['file'], '/' );
		}
		if ( ! empty( $entry['url'] ) ) {
			$urls[] = (string) $entry['url'];
		}
		$importer = new ImportService();
		foreach ( $urls as $url ) {
			$att = $importer->sideloadRemoteImage( $url, [ 'title' => $entry['title'] ?? '', 'alt' => $entry['alt'] ?? '' ] );
			if ( ! is_wp_error( $att ) ) {
				$this->logRow( $preset, 'attachment', $src_key, (int) $att );
				return (int) $att;
			}
		}
		$job['warnings'][] = 'Image skipped: ' . $src_key;
		return null;
	}

	// ══════════════════════════════════════════════════════════════════════════
	// Log + row helpers
	// ══════════════════════════════════════════════════════════════════════════

	/** Local id for a (preset, type, key) from the log; 0 when unknown or the row is gone. */
	private function local( string $preset, string $type, $key ): int {
		if ( $this->log_cache_preset !== $preset ) {
			$this->log_cache        = [];
			$this->log_cache_preset = $preset;
			foreach ( $this->db->getArray( "SELECT entity_type, source_key, local_id FROM {$this->log} WHERE preset = ?s", $preset ) as $r ) {
				$this->log_cache[ $r['entity_type'] ][ $r['source_key'] ] = (int) $r['local_id'];
			}
		}
		return (int) ( $this->log_cache[ $type ][ (string) $key ] ?? 0 );
	}

	private function logRow( string $preset, string $type, $key, int $local_id, ?string $fingerprint = null ): void {
		$key = (string) $key;
		$id  = (int) $this->db->getField( "SELECT id FROM {$this->log} WHERE preset = ?s AND entity_type = ?s AND source_key = ?s", $preset, $type, $key );
		$now = gmdate( 'Y-m-d H:i:s' );
		if ( $id ) {
			$this->db->update( 'demo_import_log', [ 'local_id' => $local_id, 'fingerprint' => $fingerprint, 'imported_at' => $now ], [ 'id' => $id ] );
		} else {
			$this->db->insert( 'demo_import_log', [ 'preset' => $preset, 'entity_type' => $type, 'source_key' => $key, 'local_id' => $local_id, 'fingerprint' => $fingerprint, 'imported_at' => $now ] );
		}
		$this->log_cache[ $type ][ $key ] = $local_id;
	}

	private function stampFingerprint( string $preset, string $type, string $key, int $local_id ): void {
		$fp = $this->fingerprint( $type, $local_id, $key, $preset );
		$this->db->query( "UPDATE {$this->log} SET fingerprint = ?s WHERE preset = ?s AND entity_type = ?s AND source_key = ?s", (string) $fp, $preset, $type, $key );
	}

	/** INSERT when $id is 0 (or the row vanished), else UPDATE. Returns the id. */
	private function upsertRow( string $table, int $id, array $data ): int {
		if ( $id > 0 ) {
			$exists = (int) $this->db->getField( "SELECT id FROM {$this->db->table( $table )} WHERE id = ?i", $id );
			if ( $exists ) {
				$this->db->update( $table, $data, [ 'id' => $id ] );
				return $id;
			}
		}
		return (int) $this->db->insert( $table, $data );
	}

	private function writeTranslations( string $table, string $fk, int $id, array $translations, array $cols ): void {
		$this->db->query( "DELETE FROM {$this->db->table( $table )} WHERE `{$fk}` = ?i", $id );
		if ( empty( $translations ) ) {
			$translations = [ 'en' => [] ];
		}
		foreach ( $translations as $lang => $vals ) {
			$row = [ $fk => $id, 'language_code' => substr( (string) $lang, 0, 2 ) ];
			foreach ( $cols as $c ) {
				$row[ $c ] = $vals[ $c ] ?? ( 'name' === $c ? '' : null );
			}
			$this->db->insert( $table, $row );
		}
	}

	/** Keep the package slug unless another row (not ours) already owns it. */
	private function uniqueSlug( string $table, string $slug, int $own_id ): string {
		$slug = sanitize_title( $slug ) ?: 'item';
		$base = $slug;
		$n    = 2;
		while ( true ) {
			$other = (int) $this->db->getField( "SELECT id FROM {$this->db->table( $table )} WHERE slug = ?s AND deleted_at IS NULL LIMIT 1", $slug );
			if ( ! $other || $other === $own_id ) {
				return $slug;
			}
			$slug = $base . '-' . $n++;
		}
	}

	private function uniqueSku( string $sku, int $own_id ): string {
		if ( '' === $sku ) {
			return '';
		}
		$base = $sku;
		$n    = 2;
		while ( true ) {
			$other = (int) $this->db->getField( "SELECT id FROM {$this->db->table('products')} WHERE sku = ?s AND deleted_at IS NULL LIMIT 1", $sku );
			if ( ! $other || $other === $own_id ) {
				return $sku;
			}
			$sku = $base . '-' . $n++;
		}
	}
}
