<?php
/**
 * Plugin Name: TitanCart Lite
 * Plugin URI:  https://titancart.io
 * Description: A self-hosted eCommerce engine for WordPress. No extra plugins. No bloat. Just performance.
 * Version:     0.2.30
 * Author:      TitanCart
 * Text Domain: titancart-lite
 * Requires at least: 6.0
 * Requires PHP: 8.0
 * License:     GPL-2.0-or-later
 *
 * Marketplace headers above wire the host plugin into the same LicenseManager
 * + UpdateManager pipelines used by paid extensions. With these headers:
 *   - LicenseManager::list_marketplace_plugins() includes core in its scan,
 *     so the existing activate/deactivate/check infrastructure works against
 *     slug 'titancart' just like any paid extension.
 *   - UpdateManager hooks WP's update system to receive new beta builds and
 *     post-launch releases from market.titancart.io natively.
 */

defined( 'ABSPATH' ) || exit;

// ── PINNED BY THE LITE BUILD (build/build.php) ───────────────────────────────
// This file is generated. Do not edit by hand; edit the source and rebuild.
//
// TITANCART_EDITION      — resolves every capability + extension gate to the
//                          Lite tier (src/Core/Edition.php). Overrides all else.
// TITANCART_NO_PHONEHOME — hard-disables LicenseManager / UpdateManager, which
//                          are not present in this package. It also
//                          short-circuits titancart_core_license_active() so
//                          this build never asks for a key.
define( 'TITANCART_EDITION', 'lite' );
define( 'TITANCART_NO_PHONEHOME', true );

// ── Constants ────────────────────────────────────────────────────────────────
define( 'TITANCART_VERSION',     '0.2.30' );
define( 'TITANCART_FILE',        __FILE__ );
define( 'TITANCART_PATH',        plugin_dir_path( __FILE__ ) );
define( 'TITANCART_URL',         plugin_dir_url( __FILE__ ) );
define( 'TITANCART_SRC',         TITANCART_PATH . 'src/' );
define( 'TITANCART_ASSETS_URL',  TITANCART_URL  . 'assets/' );
define( 'TITANCART_ADMIN_URL',   TITANCART_URL  . 'admin/build/' );
define( 'TITANCART_TABLE_PREFIX', 'tc_' );
define( 'TITANCART_MIN_PHP',     '8.0' );
define( 'TITANCART_MIN_WP',      '6.0' );


// ── Beta mode — REMOVED (KI-41, S416) ────────────────────────────────────────
//
// The beta-mode constant controlled the Account & License admin page presentation during
// the public beta (a primary "Beta Tester Access" card + a "Coming at launch"
// overlay on the subscription card) and gated BetaNoticeManager plus the
// wp-admin beta banner. The beta program is over: the constant,
// BetaNoticeManager, the beta-status feed server, and every consumer are
// removed. The Account & License page now always renders the subscription
// framing.
//
// ⚠️ DO NOT REINTRODUCE A BETA FLAG HERE. Nothing reads the old beta constant any more;
// a re-added define would be inert but misleading.

// ── Trial constants — REMOVED (KI-40, S405) ──────────────────────────────────
//
// The two trial-length constants defined a 30+7 day auto-started free
// trial. The trial is RETIRED: Lite is the trial now. An unkeyed Pro build
// resolves to 'lite' via Edition::resolve() step 4 and simply runs with the
// Lite feature set — no clock, no grace window, no replacement timer.
//
// ⚠️ DO NOT REINTRODUCE A CLOCK HERE. The retired trial was revenue-affecting
// (KI-40): `tc_trial_started_at` had exactly ONE writer (seeds-if-missing at
// activation) and ZERO deleters, and licence activation never wrote it — so
// the window ticked from first activation and was merely never CONSULTED
// while a key was live. When a key lapsed, the trial arm asked only "is the
// window open NOW?", handing back the unused remainder as free Pro. Any
// re-added clock inherits that defect unless activation consumes it.
//
// ⚠️ The comment that used to live here claimed the trial "gates the
// storefront once expired". It never did — S366 proved no such gate exists.
// It was already stale when it was written. Do not port it forward.

// ── Distribution build constants ──────────────────────────────────────────────
// TITANCART_NO_PHONEHOME is pinned to TRUE by the WP.org "TitanCart Lite" package and
// by nothing else. It is the single switch that hard-disables every outbound
// call to titancart.io / market.titancart.io — LicenseManager and UpdateManager.
// Both classes are *physically absent* from the Lite zip (see build/manifest.php);
// the guards that read this constant exist so the shared codebase never fatals on
// their absence, and so a developer can force the phone-home-free posture on any
// install for testing.
//
// Never define this on a self-hosted Pro install — it would silently disable
// license checks and the update channel.
if ( ! defined( 'TITANCART_NO_PHONEHOME' ) ) {
	define( 'TITANCART_NO_PHONEHOME', false );
}

// ── Prism theme — UNBUNDLE, stage N+1 (KI-113, S472) ─────────────────────────
// Through 0.2.16 the PRO package carried Prism at `titancart/themes/titancart-prism/`
// and registered that folder as an extra theme root (Lite never bundled it — the
// manifest's `bundle` key is Pro-only, so on Lite this block is a no-op). On any
// site that ALSO installed Prism from market that produced two copies of the same
// slug, with WordPress running whichever root was active when the theme was chosen
// and the other copy a silent stale duplicate (KI-113, found S471 on demoxpert).
//
// From this release Prism is a standalone theme. The bundled folder ships ONE
// last time so existing sites migrate themselves: on boot we copy it into
// wp-content/themes (once, atomically, version-aware), repoint the active-theme
// root options at the default root, and no longer register the extra root. The
// N+2 build stops shipping `themes/` and this whole block becomes a no-op
// (is_dir false) — it is self-retiring.
//
// Runs at plugin-file scope on purpose: plugins load before `setup_theme`, which
// is where WordPress resolves the active theme's root. Cheap after first
// success: one autoloaded option read + one is_dir per request.
//
// Fallback: if the copy cannot be made (wp-content/themes not writable) the
// bundled root is registered exactly as before so the storefront keeps running,
// and a one-time admin notice asks for Prism to be installed from market.
//
// ⚠️ Pattern #685 flips once this boots: the LIVE Prism is wp-content/themes/
// again. `ReflectionFunction('tct_prism_theme_options_schema')->getFileName()`
// still names the running copy — trust it, not this comment.

define( 'TITANCART_PRISM_SLUG', 'titancart-prism' );
define( 'TITANCART_PRISM_UNBUNDLED_OPT', 'titancart_prism_unbundled' );       // = TITANCART_VERSION once copy-out succeeded on this plugin version
define( 'TITANCART_PRISM_UNBUNDLE_NOTICE_OPT', 'titancart_prism_unbundle_notice' ); // '1' copy failed, notice pending · '2' dismissed

/**
 * Read a theme's `Version:` header without touching WP_Theme (which would consult
 * the very theme roots we are in the middle of rewiring).
 */
function titancart_prism_read_version( string $dir ): string {
	$file = $dir . '/style.css';
	if ( ! is_file( $file ) ) {
		return '';
	}
	$data = get_file_data( $file, array( 'Version' => 'Version' ) );
	return isset( $data['Version'] ) ? (string) $data['Version'] : '';
}

/** Recursive copy. Returns false on the first failed mkdir/copy. */
function titancart_prism_copy_tree( string $src, string $dst ): bool {
	if ( ! wp_mkdir_p( $dst ) ) {
		return false;
	}
	$it = new RecursiveIteratorIterator(
		new RecursiveDirectoryIterator( $src, FilesystemIterator::SKIP_DOTS ),
		RecursiveIteratorIterator::SELF_FIRST
	);
	foreach ( $it as $item ) {
		$target = $dst . '/' . $it->getSubPathname();
		if ( $item->isDir() ) {
			if ( ! wp_mkdir_p( $target ) ) {
				return false;
			}
		} elseif ( ! @copy( $item->getPathname(), $target ) ) {
			return false;
		}
	}
	return true;
}

/** Recursive delete — only ever pointed at the `.tmp-*` / `.old-*` folders this file creates. */
function titancart_prism_rmdir( string $dir ): void {
	if ( ! is_dir( $dir ) ) {
		return;
	}
	$it = new RecursiveIteratorIterator(
		new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
		RecursiveIteratorIterator::CHILD_FIRST
	);
	foreach ( $it as $item ) {
		$item->isDir() ? @rmdir( $item->getPathname() ) : @unlink( $item->getPathname() );
	}
	@rmdir( $dir );
}

/**
 * Stage into a sibling temp folder, then swap with rename() so no request ever
 * sees a half-copied theme. An existing (older) copy is moved aside and removed
 * only after the new one is in place — identical to what a theme update does.
 */
function titancart_prism_copy_dir( string $src, string $dst ): bool {
	$tmp = $dst . '.tmp-' . getmypid();
	if ( ! titancart_prism_copy_tree( $src, $tmp ) ) {
		titancart_prism_rmdir( $tmp );
		return false;
	}
	if ( is_dir( $dst ) ) {
		$old = $dst . '.old-' . time();
		if ( ! @rename( $dst, $old ) ) {
			titancart_prism_rmdir( $tmp );
			return false;
		}
		if ( ! @rename( $tmp, $dst ) ) {
			@rename( $old, $dst );
			titancart_prism_rmdir( $tmp );
			return false;
		}
		titancart_prism_rmdir( $old );
		return true;
	}
	if ( ! @rename( $tmp, $dst ) ) {
		// A concurrent request may have won the race — accept its copy if it is complete.
		titancart_prism_rmdir( $tmp );
		return is_file( $dst . '/style.css' );
	}
	return true;
}

/**
 * When Prism (or a child of it) is the active theme, point the stored root
 * options at the default theme root. WordPress stores the bundled root as the
 * RELATIVE `/plugins/titancart/themes`; with the extra root no longer registered
 * that value would still resolve to the bundled copy while it exists (N+1) and
 * to nothing at all once it is gone (N+2) — so this rewrite is not optional.
 */
function titancart_prism_repoint_roots(): void {
	$changed = false;
	foreach ( array( 'stylesheet' => 'stylesheet_root', 'template' => 'template_root' ) as $theme_opt => $root_opt ) {
		if ( TITANCART_PRISM_SLUG !== get_option( $theme_opt ) ) {
			continue;
		}
		$current = (string) get_option( $root_opt, '' );
		if ( '' === $current || '/themes' === $current ) {
			continue;
		}
		update_option( $root_opt, '/themes' );
		$changed = true;
	}
	if ( $changed ) {
		delete_site_transient( 'theme_roots' );
	}
}

/**
 * The migration. Returns true when the wp-content copy is in place (or nothing
 * is bundled), false when the copy could not be made and the caller must fall
 * back to registering the bundled root.
 */
function titancart_prism_unbundle(): bool {
	$src = TITANCART_PATH . 'themes/' . TITANCART_PRISM_SLUG;
	$dst = WP_CONTENT_DIR . '/themes/' . TITANCART_PRISM_SLUG;

	if ( ! is_dir( $src ) ) {
		return true; // N+2 posture (or dev repo): nothing bundled, nothing to migrate.
	}
	if ( TITANCART_VERSION === get_option( TITANCART_PRISM_UNBUNDLED_OPT ) && is_dir( $dst ) ) {
		return true; // Fast path — already migrated on this plugin version.
	}

	$src_ver   = titancart_prism_read_version( $src );
	$need_copy = ! is_file( $dst . '/style.css' )
		|| version_compare( titancart_prism_read_version( $dst ), $src_ver, '<' );

	if ( $need_copy ) {
		$themes_root = WP_CONTENT_DIR . '/themes';
		if ( is_dir( $themes_root ) && ! wp_is_writable( $themes_root ) ) {
			return false;
		}
		if ( ! titancart_prism_copy_dir( $src, $dst ) ) {
			return false;
		}
		delete_site_transient( 'theme_roots' );
	}

	titancart_prism_repoint_roots();
	update_option( TITANCART_PRISM_UNBUNDLED_OPT, TITANCART_VERSION, true );
	return true;
}

if ( function_exists( 'register_theme_directory' ) && is_dir( TITANCART_PATH . 'themes' ) ) {
	if ( ! titancart_prism_unbundle() ) {
		// Copy failed — keep the pre-0.2.17 behaviour so the storefront stays up.
		register_theme_directory( TITANCART_PATH . 'themes' );
		if ( '' === get_option( TITANCART_PRISM_UNBUNDLE_NOTICE_OPT, '' ) ) {
			update_option( TITANCART_PRISM_UNBUNDLE_NOTICE_OPT, '1', false );
			error_log( 'TitanCart: could not copy the bundled Prism theme into ' . WP_CONTENT_DIR . '/themes — running it from the plugin folder instead (KI-113).' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		}
	}
}

// One-time notice for the copy-failed fallback. Both callbacks run after `init`,
// so translation here is safe (#675). Dismiss stores '2' so it never returns.
add_action( 'admin_init', function () {
	if ( isset( $_GET['tc_dismiss_prism_unbundle'] ) && current_user_can( 'manage_options' ) && check_admin_referer( 'tc_dismiss_prism_unbundle' ) ) {
		update_option( TITANCART_PRISM_UNBUNDLE_NOTICE_OPT, '2', false );
	}
} );
add_action( 'admin_notices', function () {
	if ( '1' !== get_option( TITANCART_PRISM_UNBUNDLE_NOTICE_OPT, '' ) || ! current_user_can( 'manage_options' ) ) {
		return;
	}
	$dismiss = wp_nonce_url( add_query_arg( 'tc_dismiss_prism_unbundle', '1' ), 'tc_dismiss_prism_unbundle' );
	echo '<div class="notice notice-warning"><p>'
		. esc_html__( 'TitanCart could not copy the Prism theme into wp-content/themes because that folder is not writable. Prism is still running from the TitanCart plugin folder for now, but from the next TitanCart release it will only be available as a separate download. Please install TitanCart Prism from market.titancart.io, or make wp-content/themes writable and reload this page.', 'titancart-lite' )
		. ' <a href="https://market.titancart.io/" target="_blank" rel="noopener">' . esc_html__( 'Get Prism', 'titancart-lite' ) . '</a>'
		. ' · <a href="' . esc_url( $dismiss ) . '">' . esc_html__( 'Got it', 'titancart-lite' ) . '</a></p></div>';
} );

// KI-113 N+2 (0.2.20): the Pro package no longer bundles Prism. A site that
// selected Prism while it was bundled (<0.2.17) and jumped straight past N+1
// has no wp-content copy to run and WordPress will fall back to a default
// theme — say so, in the admin, with the fix. No-op everywhere else (both
// roots checked; the N+1 block above is the writer of the wp-content copy).
add_action( 'admin_notices', function () {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}
	$selected = in_array( TITANCART_PRISM_SLUG, array( get_option( 'stylesheet' ), get_option( 'template' ) ), true );
	if ( ! $selected ) {
		return;
	}
	$in_content = is_dir( WP_CONTENT_DIR . '/themes/' . TITANCART_PRISM_SLUG );
	$in_plugin  = is_dir( TITANCART_PATH . 'themes/' . TITANCART_PRISM_SLUG );
	if ( $in_content || $in_plugin ) {
		return;
	}
	echo '<div class="notice notice-error"><p>'
		. esc_html__( 'Your store is set to the TitanCart Prism theme, but Prism is not installed. Since TitanCart 0.2.20 the theme is a separate free download: install it from market.titancart.io (Appearance → Themes → Add New → Upload Theme) and activate it.', 'titancart-lite' )
		. ' <a href="https://market.titancart.io/product/titancart-prism-theme/" target="_blank" rel="noopener">' . esc_html__( 'Get Prism', 'titancart-lite' ) . '</a></p></div>';
} );

// ── Debug logging ─────────────────────────────────────────────────────────────
// To enable WordPress debug logging, add to wp-config.php:
//   define( 'WP_DEBUG', true );
//   define( 'WP_DEBUG_LOG', true );
//   define( 'WP_DEBUG_DISPLAY', false );

// ── PHP / WP version guard ───────────────────────────────────────────────────
if ( version_compare( PHP_VERSION, TITANCART_MIN_PHP, '<' ) ) {
    add_action( 'admin_notices', function () {
        echo '<div class="notice notice-error"><p>';
        /* translators: %s: required PHP version */
        printf( esc_html__( 'TitanCart requires PHP %s or higher.', 'titancart-lite' ), esc_html( TITANCART_MIN_PHP ) );
        echo '</p></div>';
    } );
    return;
}

// ── Autoloader ───────────────────────────────────────────────────────────────
require_once TITANCART_SRC . 'Core/Autoloader.php';
\TitanCart\Core\Autoloader::register();

// ── Activation / Deactivation / Uninstall hooks ──────────────────────────────
register_activation_hook(   __FILE__, [ \TitanCart\Core\Installer::class, 'activate' ] );
register_deactivation_hook( __FILE__, [ \TitanCart\Core\Installer::class, 'deactivate' ] );

// ── Global helper functions ───────────────────────────────────────────────────
// These are intentionally in the global namespace so every module, template,
// and third-party code can call them without a use-statement or namespace prefix.

if ( ! function_exists( 'TC' ) ) {
    /**
     * Return the TitanCart plugin service container instance.
     *
     * @return \TitanCart\Core\Plugin
     */
    function titancart(): \TitanCart\Core\Plugin {
        return \TitanCart\Core\Plugin::instance();
    }
}

if ( ! function_exists( 'titancart_db' ) ) {
    /**
     * Return the TitanCart Database (ORM) instance.
     *
     * @return \TitanCart\DB\Database
     */
    function titancart_db(): \TitanCart\DB\Database {
        return \TitanCart\Core\Plugin::service( 'db' );
    }
}

if ( ! function_exists( 'titancart_log' ) ) {
    /**
     * Central diagnostic logger. Silent in production unless WP_DEBUG or the
     * dedicated TITANCART_LOG constant is enabled, so live stores can opt into
     * TitanCart logging without turning on full WP_DEBUG.
     *
     * This is the plugin's single logging seam (Rule 4: portable engine —
     * a future standalone build swaps the transport here, nowhere else).
     *
     * @param string $message Already-formatted log line.
     */
    function titancart_log( string $message ): void {
        if ( ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || ( defined( 'TITANCART_LOG' ) && TITANCART_LOG ) ) {
            error_log( $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- the plugin's single, debug-gated logging seam; every call site routes through here and is silent unless WP_DEBUG or TITANCART_LOG is on.
        }
    }
}

if ( ! function_exists( 'titancart_shop_url' ) ) {
    /**
     * Get the shop index URL, respecting SEO settings.
     */
    function titancart_shop_url(): string {
        return \TitanCart\Services\SeoService::get_shop_url();
    }
}

if ( ! function_exists( 'titancart_product_url' ) ) {
    /**
     * Build a product URL from a slug, respecting SEO settings.
     */
    function titancart_product_url( string $slug ): string {
        return \TitanCart\Services\SeoService::product_url( $slug );
    }
}

if ( ! function_exists( 'titancart_category_url' ) ) {
    /**
     * Build a category URL from a slug, respecting SEO settings.
     */
    function titancart_category_url( string $slug ): string {
        return \TitanCart\Services\SeoService::category_url( $slug );
    }
}

if ( ! function_exists( 'titancart_product_ribbons_html' ) ) {
    /**
     * Render the core Product Ribbons stack HTML for a product row.
     *
     * Used by core templates + storefront themes on every product-card and PDP
     * image wrapper. Returns '' when no ribbon applies, so callers may echo it
     * unconditionally. The accompanying CSS is emitted once in wp_head by
     * \TitanCart\Storefront\ProductRibbons::output_css() (wired in
     * StorefrontModule). Self-sufficient: lazily fetches created_at / featured
     * / sales_count by id when the surface row does not carry them.
     *
     * @param array|object $product  Product row (array or stdClass).
     * @param bool|null    $on_sale  Pre-computed on-sale flag (null = derive
     *                               from list_price > price).
     */
    function titancart_product_ribbons_html( $product, ?bool $on_sale = null ): string {
        return \TitanCart\Storefront\ProductRibbons::render( $product, $on_sale );
    }
}

// ── Image-set helpers (0.2.27) ────────────────────────────────────────────────
// The `image_set` settings field type: one Theme Options control that
// art-directs a picture per device (desktop / tablet / phone image + crop
// anchor). Themes read it with these three helpers; see
// \TitanCart\Storefront\ImageSet for the stored shape and the cascade rules.

if ( ! function_exists( 'titancart_image_set' ) ) {
    /**
     * Decode + cascade an `image_set` value into per-device slots.
     *
     * @param  mixed $value Stored value (JSON, legacy URL, array or '').
     * @param  array $args  { focus?: string  default crop anchor for a legacy URL }
     * @return array{has:bool,desktop:array,tablet:array,phone:array}
     */
    function titancart_image_set( $value, array $args = [] ): array {
        return \TitanCart\Storefront\ImageSet::resolve( $value, $args );
    }
}

if ( ! function_exists( 'titancart_image_set_picture' ) ) {
    /**
     * `<picture>` markup for a resolved image set ('' when empty).
     *
     * @param array $set   From titancart_image_set().
     * @param array $attrs `<img>` attributes; `picture_class` goes on the `<picture>`.
     */
    function titancart_image_set_picture( array $set, array $attrs = [] ): string {
        return \TitanCart\Storefront\ImageSet::picture( $set, $attrs );
    }
}

if ( ! function_exists( 'titancart_image_set_css_vars' ) ) {
    /**
     * `--{prefix}-pos-d/-t/-p` custom properties for a style="" attribute.
     *
     * @param array  $set    From titancart_image_set().
     * @param string $prefix Custom-property prefix, e.g. 'tct-hero'.
     */
    function titancart_image_set_css_vars( array $set, string $prefix ): string {
        return \TitanCart\Storefront\ImageSet::css_vars( $set, $prefix );
    }
}

// ── Variation-group helpers (KI-21) ───────────────────────────────────────────
// Each variation (e.g. a specific Color/Size) is its OWN product, linked into a
// group by a row in tc_product_group_products. That row pins the member's
// combination ({feature_id: variant_id}); the group's feature_ids are the axes.
// A "variation member" must only be added to the cart from its own PDP — where
// the customer has effectively chosen that Color/Size — never via a bare card
// "Add to cart" that would silently add whichever member is listed. These
// helpers let listing surfaces flag such products ("Choose Options" -> PDP) and
// let CartManager enforce the same rule server-side. Global namespace so
// templates, REST controllers and modules can call them without a use-statement.

if ( ! function_exists( 'titancart_variation_group_for' ) ) {
    /**
     * Return the variation-group membership row for a product, or null.
     *
     * Memoized per-request per product_id.
     *
     * @param  int $product_id
     * @return array{group_id:int, combination:array<int,int>}|null
     *         combination is normalized to int feature_id => int variant_id.
     */
    function titancart_variation_group_for( int $product_id ): ?array {
        static $cache = [];

        if ( $product_id <= 0 ) {
            return null;
        }
        if ( array_key_exists( $product_id, $cache ) ) {
            return $cache[ $product_id ];
        }

        $db  = titancart_db();
        $row = $db->getRow(
            "SELECT group_id, combination
             FROM {$db->table( 'product_group_products' )}
             WHERE product_id = ?i
             LIMIT 1",
            $product_id
        );

        if ( ! $row ) {
            return $cache[ $product_id ] = null;
        }

        $combo_raw = json_decode( (string) ( $row['combination'] ?? '' ), true ) ?: [];
        $combo     = [];
        foreach ( $combo_raw as $fid => $vid ) {
            $combo[ (int) $fid ] = (int) $vid;
        }

        return $cache[ $product_id ] = [
            'group_id'    => (int) $row['group_id'],
            'combination' => $combo,
        ];
    }
}

if ( ! function_exists( 'titancart_is_variation_product' ) ) {
    /**
     * True when the product is a member of a variation group — i.e. its
     * Color/Size is fixed by membership and must be chosen on the PDP.
     *
     * @param  int $product_id
     * @return bool
     */
    function titancart_is_variation_product( int $product_id ): bool {
        return titancart_variation_group_for( $product_id ) !== null;
    }
}

if ( ! function_exists( 'titancart_variation_member_ids' ) ) {
    /**
     * Given a list of product IDs, return the subset that are variation
     * members — ONE batched query. Listing endpoints use this to flag cards
     * with `has_variations` without an N+1 membership lookup.
     *
     * @param  int[] $product_ids
     * @return int[] The input IDs that have a tc_product_group_products row.
     */
    function titancart_variation_member_ids( array $product_ids ): array {
        $ids = array_values( array_unique( array_filter( array_map( 'intval', $product_ids ) ) ) );
        if ( empty( $ids ) ) {
            return [];
        }

        $db  = titancart_db();
        $col = $db->getColumn(
            "SELECT DISTINCT product_id
             FROM {$db->table( 'product_group_products' )}
             WHERE product_id IN (?a)",
            $ids
        );

        return array_map( 'intval', $col );
    }
}

// ── Shared-variation helpers (S283) ───────────────────────────────────────────
// A "shared variation" product is a SINGLE product whose Color/Size (etc.) the
// customer picks on its OWN PDP — its feature values carry feature.purpose
// 'variation_shared' (contrast 'variation_split', where each combination is a
// separate member product, handled by the group helpers above). Such a product
// must NOT be added straight from a card either: the buyer has to choose a
// variant for each shared axis first, exactly like a grouped member. These
// helpers let listing surfaces flag the card ("Choose Options" -> PDP) and let
// CartManager enforce that a valid variant was chosen for every shared axis.

if ( ! function_exists( 'titancart_product_shared_variation_map' ) ) {
    /**
     * Map of a product's shared-variation axes to their allowed variant ids.
     *
     * Memoized per-request per product_id.
     *
     * @param  int $product_id
     * @return array<int,int[]>  feature_id => list of allowed variant_ids
     *                           (empty when the product has no shared variations).
     */
    function titancart_product_shared_variation_map( int $product_id ): array {
        static $cache = [];

        if ( $product_id <= 0 ) {
            return [];
        }
        if ( array_key_exists( $product_id, $cache ) ) {
            return $cache[ $product_id ];
        }

        $db   = titancart_db();
        $rows = $db->getArray(
            "SELECT fv.feature_id, fv.variant_id
             FROM {$db->table( 'product_feature_values' )} fv
             JOIN {$db->table( 'product_features' )} f ON f.id = fv.feature_id
             WHERE fv.product_id = ?i
               AND f.purpose = ?s
               AND fv.variant_id IS NOT NULL
               AND fv.variant_id > 0",
            $product_id,
            'variation_shared'
        );

        $map = [];
        foreach ( $rows ?: [] as $r ) {
            $fid = (int) $r['feature_id'];
            $vid = (int) $r['variant_id'];
            if ( $fid > 0 && $vid > 0 ) {
                $map[ $fid ][] = $vid;
            }
        }

        return $cache[ $product_id ] = $map;
    }
}

if ( ! function_exists( 'titancart_product_shared_variation_prices' ) ) {
    /**
     * Per-variant price modifiers of a product's shared-variation axes (S478).
     *
     * A shared-variation product has ONE base price; the store owner may give a
     * chosen variant a modifier (e.g. Size Large +5.00, Small -2.00) on the
     * product's Attributes tab. It is stored in tc_product_feature_values.value_float
     * on that product's variant row (the column is unused for variant rows), so
     * no schema change. Zero / NULL = no modifier. Read by the PDP (price
     * preview), CartManager::add_item() (unit price) and the cart line display.
     *
     * Memoized per-request per product_id.
     *
     * @param  int $product_id
     * @return array<int,array<int,float>>  feature_id => [ variant_id => modifier ]
     *                                      (only non-zero modifiers are listed).
     */
    function titancart_product_shared_variation_prices( int $product_id ): array {
        static $cache = [];

        if ( $product_id <= 0 ) {
            return [];
        }
        if ( array_key_exists( $product_id, $cache ) ) {
            return $cache[ $product_id ];
        }

        $db   = titancart_db();
        // S478 (Sean): a product's own amount (value_float, may be 0 to opt out)
        // wins; otherwise the choice's site-wide amount applies when the attribute
        // has "site-wide prices" switched on (features.global_prices = 1).
        $rows = $db->getArray(
            "SELECT fv.feature_id, fv.variant_id, fv.value_float, v.price_modifier, f.global_prices
             FROM {$db->table( 'product_feature_values' )} fv
             JOIN {$db->table( 'product_features' )} f ON f.id = fv.feature_id
             LEFT JOIN {$db->table( 'product_feature_variants' )} v ON v.id = fv.variant_id
             WHERE fv.product_id = ?i
               AND f.purpose = ?s
               AND fv.variant_id IS NOT NULL
               AND fv.variant_id > 0",
            $product_id,
            'variation_shared'
        );

        $map = [];
        foreach ( $rows ?: [] as $r ) {
            $fid = (int) $r['feature_id'];
            $vid = (int) $r['variant_id'];
            if ( $fid <= 0 || $vid <= 0 ) {
                continue;
            }
            $mod = null;
            if ( $r['value_float'] !== null && $r['value_float'] !== '' ) {
                $mod = (float) $r['value_float'];
            } elseif ( ! empty( $r['global_prices'] ) && $r['price_modifier'] !== null && $r['price_modifier'] !== '' ) {
                $mod = (float) $r['price_modifier'];
            }
            if ( null !== $mod && 0.0 !== round( $mod, 4 ) ) {
                $map[ $fid ][ $vid ] = round( $mod, 4 );
            }
        }

        return $cache[ $product_id ] = $map;
    }
}

if ( ! function_exists( 'titancart_shared_variation_modifier_total' ) ) {
    /**
     * Sum of the modifiers for a chosen set of shared variants (S478).
     *
     * @param  int              $product_id
     * @param  array<int,int>   $chosen  feature_id => variant_id (cart meta shared_variants).
     * @return float
     */
    function titancart_shared_variation_modifier_total( int $product_id, array $chosen ): float {
        $prices = titancart_product_shared_variation_prices( $product_id );
        if ( empty( $prices ) || empty( $chosen ) ) {
            return 0.0;
        }
        $total = 0.0;
        foreach ( $chosen as $fid => $vid ) {
            $total += (float) ( $prices[ (int) $fid ][ (int) $vid ] ?? 0 );
        }
        return round( $total, 4 );
    }
}

if ( ! function_exists( 'titancart_product_has_shared_variations' ) ) {
    /**
     * True when the product is a single "variable" product whose variant
     * (Color/Size/…) must be chosen on its own PDP before adding to cart.
     *
     * @param  int $product_id
     * @return bool
     */
    function titancart_product_has_shared_variations( int $product_id ): bool {
        return ! empty( titancart_product_shared_variation_map( $product_id ) );
    }
}

if ( ! function_exists( 'titancart_shared_variation_product_ids' ) ) {
    /**
     * Given a list of product IDs, return the subset that are shared-variation
     * products — ONE batched query (no N+1). Listing endpoints use this to flag
     * cards alongside titancart_variation_member_ids().
     *
     * @param  int[] $product_ids
     * @return int[] The input IDs that have a 'variation_shared' feature value.
     */
    function titancart_shared_variation_product_ids( array $product_ids ): array {
        $ids = array_values( array_unique( array_filter( array_map( 'intval', $product_ids ) ) ) );
        if ( empty( $ids ) ) {
            return [];
        }

        $db  = titancart_db();
        $col = $db->getColumn(
            "SELECT DISTINCT fv.product_id
             FROM {$db->table( 'product_feature_values' )} fv
             JOIN {$db->table( 'product_features' )} f ON f.id = fv.feature_id
             WHERE fv.product_id IN (?a)
               AND f.purpose = ?s
               AND fv.variant_id IS NOT NULL
               AND fv.variant_id > 0",
            $ids,
            'variation_shared'
        );

        return array_map( 'intval', $col );
    }
}

if ( ! function_exists( 'titancart_product_has_required_option' ) ) {
    /**
     * True when the product has at least one REQUIRED, active, non-file
     * product option — i.e. a bare add-to-cart would be rejected by
     * CartManager::check_required_options() until the buyer makes a choice on
     * the PDP. (The runtime locked-dependency exemption is intentionally not
     * mirrored here: routing such a product to "Choose Options" is always
     * safe, and the card flag stays a cheap single lookup.)
     *
     * Memoized per-request per product_id.
     *
     * @param  int $product_id
     * @return bool
     */
    function titancart_product_has_required_option( int $product_id ): bool {
        static $cache = [];

        if ( $product_id <= 0 ) {
            return false;
        }
        if ( array_key_exists( $product_id, $cache ) ) {
            return $cache[ $product_id ];
        }

        $db  = titancart_db();
        $hit = $db->getField(
            "SELECT 1
             FROM {$db->table( 'product_options' )}
             WHERE product_id = ?i
               AND required = 1
               AND status = ?s
               AND type <> ?s
             LIMIT 1",
            $product_id,
            'active',
            'F'
        );

        return $cache[ $product_id ] = ! empty( $hit );
    }
}

if ( ! function_exists( 'titancart_required_option_product_ids' ) ) {
    /**
     * Given a list of product IDs, return the subset that have a required,
     * active, non-file option — ONE batched query (no N+1).
     *
     * @param  int[] $product_ids
     * @return int[]
     */
    function titancart_required_option_product_ids( array $product_ids ): array {
        $ids = array_values( array_unique( array_filter( array_map( 'intval', $product_ids ) ) ) );
        if ( empty( $ids ) ) {
            return [];
        }

        $db  = titancart_db();
        $col = $db->getColumn(
            "SELECT DISTINCT product_id
             FROM {$db->table( 'product_options' )}
             WHERE product_id IN (?a)
               AND required = 1
               AND status = ?s
               AND type <> ?s",
            $ids,
            'active',
            'F'
        );

        return array_map( 'intval', $col );
    }
}

if ( ! function_exists( 'titancart_product_requires_options' ) ) {
    /**
     * True when a product requires the customer to make a selection on its PDP
     * before it can be added to the cart — because it is a variation-group
     * member (grouped/split model), a shared-variation ("variable") product,
     * OR has a required product option. Listing cards route such products to
     * "Choose Options" -> PDP instead of a direct "Add to Cart".
     *
     * @param  int $product_id
     * @return bool
     */
    function titancart_product_requires_options( int $product_id ): bool {
        return titancart_is_variation_product( $product_id )
            || titancart_product_has_shared_variations( $product_id )
            || titancart_product_has_required_option( $product_id );
    }
}

if ( ! function_exists( 'titancart_options_required_product_ids' ) ) {
    /**
     * Batched union of titancart_variation_member_ids(),
     * titancart_shared_variation_product_ids() and titancart_required_option_product_ids() —
     * the subset of the given IDs that require a PDP selection. Three queries
     * total, no N+1.
     *
     * @param  int[] $product_ids
     * @return int[]
     */
    function titancart_options_required_product_ids( array $product_ids ): array {
        $members  = titancart_variation_member_ids( $product_ids );
        $shared   = titancart_shared_variation_product_ids( $product_ids );
        $required = titancart_required_option_product_ids( $product_ids );
        return array_values( array_unique( array_merge( $members, $shared, $required ) ) );
    }
}

if ( ! function_exists( 'titancart_my_account_url' ) ) {
    /**
     * Get the customer "My Account" page URL.
     *
     * Mirrors the `^my-account/?$` rewrite rule registered in
     * \TitanCart\Storefront\StorefrontModule::add_rewrite_rules(). Currently
     * the path is hardcoded — when a future SeoService setting introduces an
     * `account_prefix` (parallel to `category_prefix` / `product_prefix`),
     * both this helper and the rewrite rule should be updated together.
     *
     * Optionally append a sub-section slug (e.g. 'orders', 'profile') to
     * deep-link into the account dashboard's section views, which the
     * `^my-account/([a-z\-]+)/?$` rewrite rule routes through the same
     * template via the `tc_account_section` query var.
     *
     * @param  string $section  Optional section slug. Defaults to ''
     *                          (account index).
     * @return string           Absolute URL with trailing slash.
     *
     * @since  S165 — Track A Module A6.
     */
    function titancart_my_account_url( string $section = '' ): string {
        $base = home_url( '/my-account/' );
        if ( $section === '' ) {
            return $base;
        }
        // Sanitize: only lowercase letters and hyphens, matching the rewrite
        // rule's `([a-z\-]+)` capture group. Anything else is silently
        // dropped to the index URL rather than producing a 404 link.
        $clean = preg_replace( '/[^a-z\-]/', '', strtolower( $section ) );
        return $clean ? $base . $clean . '/' : $base;
    }
}


if ( ! function_exists( 'titancart_format_address' ) ) {
    /**
     * Format an order's billing or shipping address as a plain-text string.
     *
     * @param  array  $order  Full order row from tc_orders.
     * @param  string $type   'billing' or 'shipping'.
     * @return string         Multi-line address (empty lines omitted).
     */
    function titancart_format_address( array $order, string $type = 'billing' ): string {
        $p    = $type . '_';
        $name = trim( ( $order[ $p . 'first_name' ] ?? '' ) . ' ' . ( $order[ $p . 'last_name' ] ?? '' ) );
        $city_line = trim(
            ( $order[ $p . 'city' ]     ?? '' ) . ', ' .
            ( $order[ $p . 'state' ]    ?? '' ) . ' ' .
            ( $order[ $p . 'postcode' ] ?? '' ),
            ', '
        );
        $parts = array_filter( [
            $name,
            $order[ $p . 'company' ]   ?? '',
            $order[ $p . 'address_1' ] ?? '',
            $order[ $p . 'address_2' ] ?? '',
            $city_line,
            strtoupper( $order[ $p . 'country' ] ?? '' ),
        ] );
        return implode( "\n", $parts );
    }
}

if ( ! function_exists( 'titancart_get_countries' ) ) {
    /**
     * Get all active countries as code => name associative array.
     *
     * @return array<string, string>  e.g. ['US' => 'United States', ...]
     */
    function titancart_get_countries(): array {
        $rows = \TitanCart\Services\AddressService::get_countries();
        $list = [];
        foreach ( $rows as $row ) {
            $list[ $row->code ] = $row->name;
        }
        return $list;
    }
}

if ( ! function_exists( 'titancart_get_states' ) ) {
    /**
     * Get states/provinces for a country code.
     *
     * @param  string $country_code  ISO 3166-1 alpha-2
     * @return array<string, string>  e.g. ['CA' => 'California', ...]
     */
    function titancart_get_states( string $country_code ): array {
        $rows = \TitanCart\Services\AddressService::get_states( $country_code );
        $list = [];
        foreach ( $rows as $row ) {
            $list[ $row->code ] = $row->name;
        }
        return $list;
    }
}

// ── Date / time / timezone helpers ───────────────────────────────────────────
//
// These wrap TitanCart's own appearance.date_format / time_format / timezone
// settings as the single source of truth for display formatting across the
// admin SPA, storefront, and emails. Storage convention (DB DATETIMEs stored
// in server-local time, per WP convention) is unchanged — only the display
// layer reads the configured timezone via wp_date().
//
// Migrating call sites: replace
//     date_i18n( get_option('date_format'), strtotime( $x ) )
// with
//     titancart_format_date( $x )
// (or titancart_format_datetime() for combined date+time output).

if ( ! function_exists( 'titancart_date_format' ) ) {
    /**
     * PHP date() format string from `appearance.date_format`.
     *
     * Setting values map as: mdy → 'm/d/Y', dmy → 'd/m/Y', ymd → 'Y-m-d'.
     */
    function titancart_date_format(): string {
        $val = \TitanCart\Services\SettingsService::get( 'appearance', 'date_format', 'mdy' );
        switch ( $val ) {
            case 'dmy': return 'd/m/Y';
            case 'ymd': return 'Y-m-d';
            case 'mdy':
            default:    return 'm/d/Y';
        }
    }
}

if ( ! function_exists( 'titancart_time_format' ) ) {
    /**
     * PHP date() format string from `appearance.time_format`.
     *
     * Setting values map as: 12 → 'g:i a', 24 → 'H:i'.
     */
    function titancart_time_format(): string {
        $val = \TitanCart\Services\SettingsService::get( 'appearance', 'time_format', '24' );
        return $val === '12' ? 'g:i a' : 'H:i';
    }
}

if ( ! function_exists( 'titancart_datetime_format' ) ) {
    /**
     * Combined date + time PHP format string.
     */
    function titancart_datetime_format(): string {
        return titancart_date_format() . ' ' . titancart_time_format();
    }
}

if ( ! function_exists( 'titancart_timezone' ) ) {
    /**
     * DateTimeZone for display rendering.
     *
     * Reads `appearance.timezone`; falls back to WP's site timezone when the
     * setting is empty or contains an invalid identifier.
     */
    function titancart_timezone(): \DateTimeZone {
        $tz = \TitanCart\Services\SettingsService::get( 'appearance', 'timezone', '' );
        if ( $tz !== '' ) {
            try {
                return new \DateTimeZone( $tz );
            } catch ( \Exception $e ) {
                // Fall through to WP's timezone on invalid identifier.
            }
        }
        return wp_timezone();
    }
}

if ( ! function_exists( 'titancart_format_date' ) ) {
    /**
     * Format a DB DATETIME string or Unix timestamp as a display date.
     *
     * Honors all three TitanCart appearance settings: date_format + timezone.
     * Returns empty string on unparseable input.
     *
     * @param string|int|null $value DATETIME string, Unix timestamp, or 'now'
     */
    function titancart_format_date( $value ): string {
        if ( $value === null || $value === '' ) {
            return '';
        }
        $ts = is_numeric( $value ) ? (int) $value : strtotime( (string) $value );
        if ( ! $ts ) {
            return '';
        }
        return wp_date( titancart_date_format(), $ts, titancart_timezone() );
    }
}

if ( ! function_exists( 'titancart_format_datetime' ) ) {
    /**
     * Format a DB DATETIME string or Unix timestamp as a combined date+time.
     *
     * @param string|int|null $value DATETIME string, Unix timestamp, or 'now'
     */
    function titancart_format_datetime( $value ): string {
        if ( $value === null || $value === '' ) {
            return '';
        }
        $ts = is_numeric( $value ) ? (int) $value : strtotime( (string) $value );
        if ( ! $ts ) {
            return '';
        }
        return wp_date( titancart_datetime_format(), $ts, titancart_timezone() );
    }
}

if ( ! function_exists( 'titancart_now_utc' ) ) {
    /**
     * Canonical "now" for DATETIME storage and validity-window comparisons.
     *
     * KI-24 (S288): TitanCart's storage contract is UTC — titancart_format_date() /
     * titancart_format_datetime() read every stored DATETIME as UTC and render it in
     * titancart_timezone() (appearance.timezone). So every "now" written to, or
     * compared against, a DATETIME column must be UTC. Use this instead of
     * current_time('mysql') (which is WP-core tz) or SQL NOW()/CURRENT_TIMESTAMP
     * (which is the MySQL session tz — Eastern on demoxpert), both of which
     * diverge from the UTC the display layer assumes.
     *
     * @return string UTC 'Y-m-d H:i:s'.
     */
    function titancart_now_utc(): string {
        return current_time( 'mysql', true );
    }
}

if ( ! function_exists( 'titancart_local_to_utc' ) ) {
    /**
     * Convert an admin-entered wall-clock datetime (in the store's display
     * timezone) to a UTC 'Y-m-d H:i:s' string for storage. Mirror of the S285
     * ShipmentManager::local_to_utc() fix, promoted to a shared helper for
     * KI-24 (Promotions windows, and any future admin-entered datetime field).
     *
     * The admin types into a <input type="datetime-local"> while looking at a
     * store rendered in titancart_timezone() (appearance.timezone, e.g.
     * America/New_York) — NOT wp_timezone() (WP core tz, UTC here). Both display
     * layers treat the stored value as UTC and render it in the store tz, so the
     * entry must round-trip store-tz -> UTC. Empty / unparseable -> null.
     *
     * @param  string|null $local  e.g. '2026-06-09T09:00' or '2026-06-09 09:00:00'
     * @return string|null UTC 'Y-m-d H:i:s', or null.
     */
    function titancart_local_to_utc( $local ): ?string {
        $local = trim( (string) ( $local ?? '' ) );
        if ( $local === '' ) {
            return null;
        }
        try {
            $dt = new \DateTime( $local, titancart_timezone() );
            $dt->setTimezone( new \DateTimeZone( 'UTC' ) );
            return $dt->format( 'Y-m-d H:i:s' );
        } catch ( \Exception $e ) {
            return null;
        }
    }
}

if ( ! function_exists( 'titancart_utc_to_input' ) ) {
    /**
     * Convert a stored UTC DATETIME back to a store-local value formatted for a
     * <input type="datetime-local"> ('Y-m-d\TH:i'), so an edit form round-trips
     * what was entered instead of showing a tz-shifted value. The inverse of
     * titancart_local_to_utc(). Empty / unparseable -> ''.
     *
     * @param  string|null $utc  stored UTC 'Y-m-d H:i:s'
     * @return string 'Y-m-d\TH:i', or ''.
     */
    function titancart_utc_to_input( $utc ): string {
        $utc = trim( (string) ( $utc ?? '' ) );
        if ( $utc === '' ) {
            return '';
        }
        try {
            $dt = new \DateTime( $utc, new \DateTimeZone( 'UTC' ) );
            $dt->setTimezone( titancart_timezone() );
            return $dt->format( 'Y-m-d\TH:i' );
        } catch ( \Exception $e ) {
            return '';
        }
    }
}

if ( ! function_exists( 'titancart_login_url' ) ) {
    /**
     * Build a login URL that lands on the themed `/my-account/` page rather
     * than WordPress's raw `/wp-login.php`.
     *
     * Why this exists (S136 fix sweep): six call sites used
     * `wp_login_url( get_permalink() )` directly. That had two problems:
     *
     *   1. `get_permalink()` returns whatever post is in the global `$post`
     *      variable. If an upstream loop (recent-posts widget, related-articles
     *      block, recently-viewed list, etc.) called `setup_postdata()` and did
     *      not reset, `get_permalink()` returns the URL of THAT post — an
     *      unrelated article — instead of the page the customer is on. Sean
     *      hit this on checkout's "Log in" link and the Reviews tab.
     *
     *   2. `wp_login_url()` returns `/wp-login.php` — the unstyled WordPress
     *      admin login screen. Customers expect the themed `/my-account/` UI
     *      that mirrors the rest of the storefront.
     *
     * This helper:
     *   - Defaults the redirect to the current URL via `$_SERVER['REQUEST_URI']`
     *     (reliable; never leaks like `get_permalink()` can).
     *   - Routes through `/my-account/`. The themed login template at
     *     `templates/account/login-register.php` honors `?redirect_to=` and
     *     forwards it to `wp_login_url()` for the actual auth POST, so the
     *     customer ultimately lands back where they started.
     *
     * Safety: the redirect target is validated by `wp_validate_redirect()` in
     * `AccountsModule::render_account()` and `login-register.php` before being
     * used as a form action, preventing open-redirect abuse.
     *
     * @param string|null $redirect Where to send the customer after login.
     *                              When null/empty, defaults to current URL.
     * @return string The themed-login URL with `?redirect_to=` appended.
     */
    function titancart_login_url( ?string $redirect = null ): string {
        if ( $redirect === null || $redirect === '' ) {
            // Build the current URL from the request; never trust `get_permalink()`.
            $request = isset( $_SERVER['REQUEST_URI'] )
                ? esc_url_raw( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) )
                : '/';
            $redirect = home_url( $request );
        }
        return add_query_arg(
            [ 'redirect_to' => urlencode( $redirect ) ],
            home_url( '/my-account/' )
        );
    }
}

// ── Vendor / extension licensing helpers ─────────────────────────────────────
//
// These are vendor-internal helpers used by every paid TitanCart extension to
// decide whether it should run with full features. The license-key + marketplace-
// API check inside each extension is fine for customer installs; vendor installs
// (titancart.io and its subdomains) get a host-based bypass so the developer
// doesn't have to manually license every extension on every dev/staging site.
//
// Customer-facing risk is zero: the bypass only triggers when home_url() resolves
// to a host the vendor controls (titancart.io / *.titancart.io). A customer site
// can never satisfy that without controlling DNS for the vendor's domain.

if ( ! function_exists( 'titancart_vendor_hosts' ) ) {
    /**
     * Hosts that auto-activate every TitanCart extension.
     *
     * Default list covers titancart.io and any subdomain. Add to this via the
     * `titancart_vendor_hosts` filter or by defining TITANCART_VENDOR_HOSTS as a comma-
     * separated list in wp-config.php (e.g. for staging hostnames not on the
     * .titancart.io tree).
     *
     * @return string[] List of host suffixes. A site host matches an entry
     *                  if it equals the entry exactly OR ends with `.{entry}`.
     */
    function titancart_vendor_hosts(): array {
        $hosts = [ 'titancart.io' ];
        if ( defined( 'TITANCART_VENDOR_HOSTS' ) && is_string( TITANCART_VENDOR_HOSTS ) && TITANCART_VENDOR_HOSTS !== '' ) {
            foreach ( array_map( 'trim', explode( ',', TITANCART_VENDOR_HOSTS ) ) as $h ) {
                if ( $h !== '' ) $hosts[] = strtolower( $h );
            }
        }
        return apply_filters( 'titancart_vendor_hosts', $hosts );
    }
}

if ( ! function_exists( 'titancart_is_vendor_site' ) ) {
    /**
     * Is the current site a vendor-controlled install (titancart.io or subdomain)?
     *
     * Detection is purely host-based on home_url(), so customer installs can
     * never satisfy this even if they obtain the plugin source. The check is
     * cached per-request via a static so repeated calls across many extensions
     * don't repeatedly parse the URL.
     *
     * Override paths:
     *   - `TITANCART_VENDOR_MODE` constant defined truthy → forced true
     *   - `titancart_is_vendor_site` filter → final say (last-write-wins)
     *
     * @return bool
     */
    function titancart_is_vendor_site(): bool {
        static $cached = null;
        if ( $cached !== null ) {
            return apply_filters( 'titancart_is_vendor_site', $cached );
        }

        // Explicit override for non-vendor-host environments (CI, local dev).
        if ( defined( 'TITANCART_VENDOR_MODE' ) && TITANCART_VENDOR_MODE ) {
            $cached = true;
            return apply_filters( 'titancart_is_vendor_site', true );
        }

        $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
        if ( $host === '' ) {
            $cached = false;
            return apply_filters( 'titancart_is_vendor_site', false );
        }

        $cached = false;
        foreach ( titancart_vendor_hosts() as $vendor_host ) {
            $vh = strtolower( $vendor_host );
            if ( $host === $vh || ( strlen( $host ) > strlen( $vh ) + 1 && substr( $host, - ( strlen( $vh ) + 1 ) ) === '.' . $vh ) ) {
                $cached = true;
                break;
            }
        }

        return apply_filters( 'titancart_is_vendor_site', $cached );
    }
}

if ( ! function_exists( 'titancart_is_lite_build' ) ) {
	/**
	 * True when this install is a pinned **Lite package** — i.e. the free,
	 * GPL, phone-home-free build distributed on WordPress.org — as opposed to
	 * merely a store that happens to resolve to the Lite *edition* right now.
	 *
	 * ─────────────────────────────────────────────────────────────────────────
	 * WHY THIS READS BUILD CONSTANTS ONLY (do not "improve" it):
	 *
	 * This function must NOT call titancart_core_edition() / Edition::resolve().
	 * Edition::resolve()'s backward-compat entitlement fallback calls
	 * titancart_core_license_active(), and titancart_core_license_active() calls THIS
	 * function. Routing through the resolver would be infinite recursion.
	 *
	 * Both constants are set at package time and are cheap, side-effect-free
	 * reads, so the check is safe to make from anywhere in the bootstrap.
	 * ─────────────────────────────────────────────────────────────────────────
	 *
	 * @return bool
	 */
	function titancart_is_lite_build(): bool {
		if ( defined( 'TITANCART_NO_PHONEHOME' ) && TITANCART_NO_PHONEHOME ) {
			return true;
		}

		return defined( 'TITANCART_EDITION' )
			&& strtolower( trim( (string) TITANCART_EDITION ) ) === 'lite';
	}
}

if ( ! function_exists( 'titancart_extension_license_active' ) ) {
    /**
     * Single source of truth for "is paid extension X licensed and active on
     * this site?" — used by every TitanCart paid extension's bootstrap.
     *
     * Order of precedence (first match wins):
     *   1. Vendor site (titancart.io / *.titancart.io / TITANCART_VENDOR_MODE) → true
     *   2. Per-extension dev-mode constant → true (legacy compat)
     *   3. LicenseManager cached status — the authoritative gate:
     *        a. DEFINITIVE rejection (expired / revoked / invalid / inactive)
     *           → false. A locally-stored key must not keep features on.
     *        b. Confirmed 'active' → true.
     *        c. SOFT / unsettled ('unknown' = transport fail-open;
     *           'limit_reached' = seat not confirmed against the cap) → true
     *           ONLY inside a bounded grace window (KI-61/62). The window is
     *           stamped when the soft state is entered and is not extended, so a
     *           single failed activation POST — or any non-empty key string while
     *           the marketplace was unreachable — can no longer mint permanent
     *           Pro. Past the window → false until a real verdict settles it.
     *        d. Any other resolved state → false.
     *   4. LicenseManager unavailable (mid-bootstrap race only): last-resort
     *      key-presence grant so a load-order glitch doesn't dark a licensed
     *      store. Dead once LicenseManager is loaded (step 3 is authoritative).
     *   5. Otherwise → false
     *
     * Extensions remain free to layer additional checks (e.g. cached marketplace
     * API verification) on top of a `true` return — this helper just decides the
     * baseline. Returning false is final, though: a `false` here means the
     * extension MUST treat itself as unlicensed.
     *
     * The result is filterable via `titancart_extension_license_active` (slug-aware)
     * for any custom logic that needs to override the default chain.
     *
     * @param string $slug      Extension slug, e.g. 'titancart-subscriptions'.
     * @param string $dev_const Optional name of a per-extension dev-mode constant
     *                          to check at step 2 (e.g. 'TCS_DEV_MODE'). Pass empty
     *                          string to skip step 2.
     * @return bool
     */
    function titancart_extension_license_active( string $slug, string $dev_const = '' ): bool {
        // 1. Vendor site bypass — primary mechanism for the developer's own sites.
        if ( titancart_is_vendor_site() ) {
            return apply_filters( 'titancart_extension_license_active', true, $slug, 'vendor_site' );
        }

        // 2. Per-extension dev-mode constant — legacy compat for extensions that
        //    shipped before this central helper existed.
        if ( $dev_const !== '' && defined( $dev_const ) && constant( $dev_const ) ) {
            return apply_filters( 'titancart_extension_license_active', true, $slug, 'dev_const' );
        }

        // 3. Consult LicenseManager's cached status — the authoritative gate.
        if ( class_exists( '\\TitanCart\\Core\\LicenseManager' ) ) {
            $status = \TitanCart\Core\LicenseManager::get_status( $slug );
            $state  = isset( $status['status'] ) ? (string) $status['status'] : 'unknown';
            $valid  = ! empty( $status['valid'] );

            // 3a. Definitive rejection → fail closed. (get_status() reports a
            //     site with no key on file as 'invalid', so that lands here too.)
            if ( ! $valid && in_array( $state, [ 'expired', 'invalid', 'inactive' ], true ) ) {
                return apply_filters( 'titancart_extension_license_active', false, $slug, 'license_' . $state );
            }

            // 3b. Confirmed active → entitled.
            if ( $valid && $state === 'active' ) {
                return apply_filters( 'titancart_extension_license_active', true, $slug, 'license_active' );
            }

            // 3c. Soft / unsettled → bounded grace only (KI-61/62). A bare or
            //     tampered key that never completed a real activation carries no
            //     grace_until stamp (grace_until = 0) and so fails closed here.
            if ( in_array( $state, [ 'unknown', 'limit_reached' ], true ) ) {
                $grace_until = isset( $status['grace_until'] ) ? (int) $status['grace_until'] : 0;
                if ( $grace_until > 0 && time() < $grace_until ) {
                    return apply_filters( 'titancart_extension_license_active', true, $slug, 'license_grace' );
                }
                return apply_filters( 'titancart_extension_license_active', false, $slug, 'license_grace_expired' );
            }

            // 3d. Any other resolved state with a status row → not an implicit grant.
            return apply_filters( 'titancart_extension_license_active', false, $slug, 'license_' . ( $state !== '' ? $state : 'unresolved' ) );
        }

        // 4. LicenseManager unavailable (mid-bootstrap race only). Last-resort
        //    key-presence grant so a transient load-order glitch doesn't dark a
        //    licensed store. Dead once LicenseManager is loaded.
        $key = trim( (string) get_option( 'titancart_license_' . $slug, '' ) );
        if ( $key !== '' ) {
            return apply_filters( 'titancart_extension_license_active', true, $slug, 'license_key_bootstrap' );
        }

        return apply_filters( 'titancart_extension_license_active', false, $slug, 'unlicensed' );
    }
}

// ── titancart_core_trial_status() — REMOVED (KI-40, S405) ───────────────────────────
//
// The auto-started 30+7 day trial is retired; Lite is the trial. This helper
// read `tc_trial_started_at` and reported active/grace/expired/never_started.
// Every caller has been removed:
//   - titancart_core_license_active() arm 3         (titancart.php)
//   - titancart_core_license_state() trial fallback (titancart.php)
//   - LicenseController GET /license         (src/REST/License/LicenseController.php)
//   - Plugin::beta_mode_notice()             (removed entirely with the beta
//     scaffolding — KI-41, S416)
//
// ⚠️ The option `tc_trial_started_at` is deliberately NOT deleted. Installer no
// longer seeds it, and since S416 (KI-41) its LAST reader is gone too:
// BetaNoticeManager::is_enrollment_blocked() used to read it as a beta
// enrolment-date proxy, but BetaNoticeManager was removed with the rest of the
// beta scaffolding. The option is now a fully inert orphan on any store that
// still carries the row — it grants nothing and is read by nothing. Left in
// place because a keyless tidy-up delete is not worth a migration; do NOT
// re-add a reader.

if ( ! function_exists( 'titancart_core_license_active' ) ) {
    /**
     * Single source of truth for "is the host TitanCart plugin entitled to
     * serve the storefront on this site?"
     *
     * Order of precedence (first match wins):
     *   0. Lite build → true (free/GPL, no key required, off the paid path)
     *   1. Vendor site (titancart.io / *.titancart.io / TITANCART_VENDOR_MODE) → true
     *   2. LicenseManager has a DEFINITIVE valid verdict for slug 'titancart' → true
     *   3. Otherwise → false (store degrades to Lite; it does NOT go dark — S366)
     *
     * ⚠️ S405 (KI-40): a fourth arm — "trial is active or in grace" — was
     * REMOVED. Lite is the trial now. Do not restore it; see the note at the
     * former arm 3 below for why it was revenue-affecting.
     *
     * Used by (⚠️ CORRECTED S366 — verified by grep, not by belief):
     *   - Edition::resolve() step 3 → an unentitled store degrades to 'lite'
     *   - Admin nag bar visibility (Plugin::license_inactive_notice)
     *   - LicenseController GET /license response
     *   - Plugin::maintenance_gate() — REGISTERED BUT OFF BY DEFAULT (KI-40)
     *
     * ⚠️ This list previously claimed "Cart / checkout REST endpoints (block
     * place_order when false)" and "StorefrontModule maintenance gate". BOTH
     * WERE FALSE. There is no place_order block: grepping titancart_core_license_active
     * across src/REST/ returns only LicenseController (which merely reports it)
     * and across src/Modules/ returns nothing at all. The gate lives in
     * Plugin.php, not StorefrontModule. Three sessions of KI-40 planning were
     * scoped against this docblock. Do not restore it.
     *
     * Filterable via `titancart_core_license_active` (last-write-wins) for any
     * customer override.
     *
     * @return bool
     */
    function titancart_core_license_active(): bool {
        // 0. LITE BUILD — free, GPL, no key required to run. Ever.
        //
        //    Everything below this line (license verdicts, key presence, the
        //    auto-started 30-day trial and its grace window) exists only for the
        //    paid Pro core. Without this short-circuit a WordPress.org install
        //    would inherit the trial chain and fall to state 'expired' on day
        //    38. Short-circuit before the trial is ever consulted.
        //
        //    ⚠️ S366: this comment used to add "AND block place_order at
        //    checkout". No such block has ever existed (see the corrected
        //    "Used by" list above). Keep the short-circuit anyway — it is what
        //    keeps a Lite install off the trial clock entirely.
        if ( titancart_is_lite_build() ) {
            return apply_filters( 'titancart_core_license_active', true, 'lite_build' );
        }

        // 1. Vendor site bypass — same mechanism as titancart_extension_license_active().
        if ( titancart_is_vendor_site() ) {
            return apply_filters( 'titancart_core_license_active', true, 'vendor_site' );
        }

        // 2. Marketplace-validated license. Reuse the existing helper which
        //    already knows the failure modes (definitive rejection vs unknown).
        if ( titancart_extension_license_active( 'titancart' ) ) {
            return apply_filters( 'titancart_core_license_active', true, 'license' );
        }

        // 3. RETIRED (KI-40, S405) — the trial arm used to grant Pro here.
        //    An unkeyed Pro build now falls through to false and degrades to
        //    Lite. That is the intended posture: Lite IS the trial.
        //
        //    ⚠️ Do NOT re-add a trial arm. This arm was revenue-affecting:
        //    the clock had no writer on licence activation and no deleter, so
        //    it was never consumed — it merely stopped being CONSULTED while
        //    arm 2 matched. On lapse it asked only "is the window open NOW?"
        //    and handed back the unused remainder as unpaid Pro.

        return apply_filters( 'titancart_core_license_active', false, 'unlicensed' );
    }
}

if ( ! function_exists( 'titancart_core_license_state' ) ) {
    /**
     * Return a single fine-grained label for the current core license state.
     *
     * Used by the admin SPA's License Status card and by the admin nag bar
     * to decide what message to display. Coarser than titancart_core_license_active
     * (which collapses to bool) — exposes the WHY behind the verdict.
     *
     * Possible return values:
     *   'lite'          — Lite build; no key, no license path.
     *   'vendor'        — Vendor site bypass (titancart.io domain).
     *   'licensed'      — Marketplace says the key is active.
     *   'expired'       — License key was rejected by marketplace (revoked / expired).
     *   'unlicensed'    — No key on file. Store degrades to Lite (NOT gated — S366).
     *
     * ⚠️ S405 (KI-40): 'trial' and 'trial_grace' are RETIRED and can no longer
     * be returned. Any consumer still branching on them is dead code.
     *
     * @return string
     */
    function titancart_core_license_state(): string {
        // Lite build has no license, no key and no trial — it simply runs.
        // 'lite' is deliberately a label no consumer special-cases: Plugin.php's
        // nag bar only reacts to 'unlicensed' / 'expired', so an unrecognised
        // label fails open (no nag, no gate), which is the correct posture.
        if ( titancart_is_lite_build() ) {
            return 'lite';
        }

        if ( titancart_is_vendor_site() ) {
            return 'vendor';
        }

        if ( class_exists( '\\TitanCart\\Core\\LicenseManager' ) ) {
            $status = \TitanCart\Core\LicenseManager::get_status( 'titancart' );
            if ( ! empty( $status['valid'] ) ) {
                return 'licensed';
            }
            // Only treat a definitive rejection as "expired" when a key is
            // actually on file (a real key was revoked / expired / over its
            // activation limit). With NO key there is nothing to expire — the
            // site is simply unlicensed, and reports as such below.
            //
            // ⚠️ S405 (KI-40): this branch used to fall through to the trial
            // state for a keyless site so the label would agree with
            // titancart_core_license_active()'s own trial arm. Both arms are now gone
            // and the two functions still agree — keyless ⇒ 'unlicensed' here,
            // false there.
            $has_key = trim( (string) get_option( 'titancart_license_titancart', '' ) ) !== '';
            $state   = isset( $status['status'] ) ? (string) $status['status'] : '';
            if ( $has_key && in_array( $state, [ 'expired', 'invalid', 'inactive' ], true ) ) {
                return 'expired';
            }
        }

        // No key / no definitive verdict ⇒ unlicensed. The store degrades to
        // Lite (Edition::resolve() step 4); it does NOT go dark — S366.
        return 'unlicensed';
    }
}

if ( ! function_exists( 'titancart_core_edition' ) ) {
    /**
     * Resolve the effective edition for this install: 'lite' | 'pro'.
     *
     * Single source of truth for the Lite/Pro gate. Delegates to
     * \TitanCart\Core\Edition::resolve() (build const TITANCART_EDITION overrides →
     * core-license `edition` field → backward-compat entitlement fallback →
     * 'lite'). See docs/TITANCART-LITE-SCOPING.md §3.
     *
     * Defensive fallback if the Edition class isn't loaded: honour a pinned
     * TITANCART_EDITION const, else keep currently-entitled installs on Pro (never
     * accidentally downgrade a live store), else Lite.
     *
     * @return string
     */
    function titancart_core_edition(): string {
        if ( class_exists( '\\TitanCart\\Core\\Edition' ) ) {
            return \TitanCart\Core\Edition::resolve();
        }

        if ( defined( 'TITANCART_EDITION' ) ) {
            $e = strtolower( trim( (string) TITANCART_EDITION ) );
            if ( $e === 'pro' || $e === 'lite' ) {
                return $e;
            }
        }

        return titancart_core_license_active() ? 'pro' : 'lite';
    }
}

if ( ! function_exists( 'titancart_is_pro' ) ) {
    /**
     * True when this install is running the Pro edition.
     *
     * @return bool
     */
    function titancart_is_pro(): bool {
        return titancart_core_edition() === 'pro';
    }
}

if ( ! function_exists( 'titancart_extension_pro_bundled' ) ) {
    /**
     * True when standalone extension $slug is bundled into Pro AND this install
     * is entitled to Pro.
     *
     * This is the entitlement primitive for the S354 distribution-model flip
     * (docs/DISTRIBUTION-MODEL.md §3). The eight Pro-bundled extension plugins
     * call it from their own tcXX_check_license_active() so that an active core
     * subscription — with NO per-extension license key — switches them on.
     * Pro *is* the key.
     *
     * `titancart-printful` and any future third-party add-on are NOT in the
     * list, so this returns false for them and their own key path is untouched.
     *
     * LAPSE BEHAVIOUR IS FREE AND FAILS CLOSED: titancart_is_pro() → titancart_core_edition()
     * → Edition::resolve() → LicenseManager, which fails closed on an expired /
     * invalid / inactive core license. On lapse this returns false and each
     * extension falls to its normal unlicensed path (admin notice, features off,
     * always-on REST). See docs/DISTRIBUTION-MODEL.md §6b.
     *
     * DEFENSIVE FALLBACK — mirrors titancart_core_edition() / titancart_can_use(): if the
     * Edition class failed to autoload we must never silently switch off a
     * paying customer's extensions, so we consult a hard-coded copy of the
     * slug list rather than returning false. The edition check still applies,
     * so a Lite build cannot leak entitlement through this path.
     *
     * @param string $slug Full plugin slug, e.g. 'titancart-wishlist-plus'.
     * @return bool
     */
    function titancart_extension_pro_bundled( string $slug ): bool {
        if ( class_exists( '\\TitanCart\\Core\\Edition' ) ) {
            if ( ! \TitanCart\Core\Edition::is_pro_bundled_extension( $slug ) ) {
                return false;
            }
            return titancart_is_pro();
        }

        // Autoload miss — err toward entitled (never lock a live store).
        // Keep in sync with Edition::pro_bundled_extensions().
        $bundled = [
            'titancart-abandoned-cart',
            'titancart-tip-donation',
            'titancart-sms-notifications',
            'titancart-product-feeds',
            'titancart-wishlist-plus',
            'titancart-marketplace',
            'titancart-affiliate-marketing',
            'titancart-subscriptions',
            'titancart-printful', // KI-45 — keep in lockstep with Edition.php.
        ];

        return in_array( $slug, $bundled, true ) && titancart_is_pro();
    }
}


if ( ! function_exists( 'titancart_subscribe_url' ) ) {
    /**
     * Canonical "buy a TitanCart subscription" URL.
     *
     * Single source of truth for the purchase link used by the beta countdown
     * banner, the grace-period warning, and the license-inactive notice — so a
     * tester always has a one-click path from "trial ending" to checkout.
     *
     * Filterable via `titancart_subscribe_url` for white-label / re-point
     * without a code change.
     *
     * @return string
     */
    function titancart_subscribe_url(): string {
        return (string) apply_filters(
            'titancart_subscribe_url',
            'https://market.titancart.io/product/titancart-ecommerce-plugin/'
        );
    }
}

if ( ! function_exists( 'titancart_client_ip' ) ) {
	/**
	 * The validated client IP from the TCP connection (REMOTE_ADDR), or ''
	 * when absent or not a well-formed IP.
	 *
	 * WP.org R1 🅱2: the single sanctioned way to capture a client IP for
	 * storage (waitlist, newsletter, reviews, downloads, comments). Reads
	 * REMOTE_ADDR ONLY — never client-supplied forwarding headers, which are
	 * trivially forgeable. Components that must honour a trusted proxy
	 * (RestHardening, the admin IP-allowlist in Plugin.php) keep their own
	 * opt-in chains and validate independently.
	 *
	 * @return string Valid IPv4/IPv6 string, or ''.
	 */
	function titancart_client_ip(): string {
		// sanitize_text_field is shape-preserving for any valid IP literal;
		// FILTER_VALIDATE_IP remains the real gate (returns '' on anything else).
		$raw = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : '';
		$ip  = filter_var( trim( $raw ), FILTER_VALIDATE_IP );
		return is_string( $ip ) ? $ip : '';
	}
}

// ── Boot ─────────────────────────────────────────────────────────────────────
add_action( 'plugins_loaded', function () {
    \TitanCart\Core\Plugin::instance()->boot();
}, 0 );
