<?php

/**
 * Product Lifecycle Hooks
 *
 * Handles product lifecycle events that affect subscriptions:
 * 1. Caches product data on subscription at creation time
 * 2. Handles product deletion/trashing - notifies admin and allows subscriptions to continue
 * 3. Handles product disabling - warns admin about affected subscriptions
 *
 * Meta fields stored on subscription for product cache:
 * - _cached_product_name: Product name at subscription creation
 * - _cached_product_sku: Product SKU
 * - _cached_product_data: Full product data JSON (for reference)
 * - _product_deleted: Flag indicating original product was deleted
 * - _product_deleted_date: When the product was deleted
 * - _product_deleted_action: 'deleted' or 'trashed'
 *
 * @package ArraySubs\Features\ProductLifecycle\Services
 * @since 1.0.0
 */

namespace ArraySubs\Features\ProductLifecycle\Services;

defined('ABSPATH') || exit;

/**
 * Product Lifecycle Hooks
 */
class Hooks
{
    /**
     * Enable auto-loading via AbstractLoader
     *
     * @var bool
     */
    public static $loadable = true;

    /**
     * Flag to prevent recursive hooks
     *
     * @var bool
     */
    private static $processing_product = false;

    /**
     * Buffer for product changes collected during the current request.
     * Keyed by product ID, each entry holds accumulated audit changes.
     *
     * @var array<int, array{name: string, actor_id: int, changes: array}>
     */
    private static $product_change_buffer = [];

    /**
     * Whether the shutdown flush has been registered.
     *
     * @var bool
     */
    private static $flush_registered = false;

    /**
     * Pre-save product snapshots keyed by product ID.
     * Captured before WordPress writes the post to the database so we
     * can diff the old product state against the new one reliably.
     *
     * @var array<int, array<string, string>>
     */
    private static $pre_save_snapshots = [];

    /**
     * Initialize hooks
     */
    public function __construct()
    {
        // Cache product data when subscription is created
        // Priority 15 to run after core subscription creation but before other features
        add_action('arraysubs_data_created_from_order', [$this, 'cacheProductData'], 15, 3);
        add_action('arraysubs_data_created', [$this, 'maybeCacheProductData'], 15, 2);

        // Capture product state BEFORE WordPress writes the post (name, slug, etc.) to the database.
        add_action('pre_post_update', [$this, 'capturePreSaveSnapshot'], 5, 2);

        // Log product CRUD activity for audits.
        add_action('wp_after_insert_post', [$this, 'logProductCreated'], 20, 4);
        add_action('woocommerce_admin_process_product_object', [$this, 'logProductAdminChanges'], 20, 1);
        add_action('woocommerce_admin_process_variation_object', [$this, 'logVariationAdminChanges'], 20, 2);

        // Handle product deletion
        add_action('before_delete_post', [$this, 'onProductDeleting'], 10, 2);
        add_action('wp_trash_post', [$this, 'onProductTrashing'], 10, 1);

        // Handle product untrashing (restored from trash)
        add_action('untrash_post', [$this, 'onProductUntrashing'], 10, 1);

        // Admin notice for affected subscriptions
        add_action('admin_notices', [$this, 'showProductDeletedNotice']);

        // Add product deletion warning in WooCommerce product edit
        add_action('woocommerce_product_options_general_product_data', [$this, 'showSubscriptionWarning']);

        // Filter to get product name for subscription (with cache fallback)
        add_filter('arraysubs_get_product_name', [$this, 'filterProductName'], 10, 2);
    }

    /**
     * Capture the product property snapshot before WordPress commits the post to the database.
     *
     * This runs on `pre_post_update` (before `wp_update_post` writes), so the WC_Product
     * object still reflects the old persisted values including product name/slug that are
     * stored in the posts table.
     *
     * @param int   $post_id Post ID.
     * @param array $data    Array of unslashed post data.
     * @return void
     */
    public function capturePreSaveSnapshot(int $post_id, array $data): void
    {
        $post_type = get_post_type($post_id);

        if ($post_type !== 'product' && $post_type !== 'product_variation') {
            return;
        }

        if (!function_exists('wc_get_product')) {
            return;
        }

        $product = wc_get_product($post_id);

        if (!$product instanceof \WC_Product) {
            return;
        }

        $snapshot = [];

        foreach (array_keys(self::getAuditableProductProps()) as $key) {
            $snapshot[$key] = $this->getProductAuditPropValue($product, $key);
        }

        self::$pre_save_snapshots[$post_id] = $snapshot;
    }

    /**
     * Log product creation events for audits.
     *
     * @param int            $post_id     Product post ID.
     * @param \WP_Post       $post        Product post object.
     * @param bool           $update      Whether the post is being updated.
     * @param \WP_Post|null  $post_before Post object before the update.
     * @return void
     */
    public function logProductCreated(int $post_id, \WP_Post $post, bool $update, $post_before): void
    {
        if ($post->post_type !== 'product' && $post->post_type !== 'product_variation') {
            return;
        }

        if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
            return;
        }

        if ($update || in_array($post->post_status, ['auto-draft', 'inherit'], true)) {
            return;
        }

        if (!function_exists('arraysubs_add_global_note')) {
            return;
        }

        $product_label = $this->getProductAuditLabel($post_id, $post);
        $actor_id      = get_current_user_id();
        $entity_label  = $post->post_type === 'product_variation'
            ? __('Product variation', 'arraysubs')
            : __('Product', 'arraysubs');

        arraysubs_add_global_note(
            sprintf(
                /* translators: 1: entity label, 2: product ID, 3: product name. */
                __('%1$s #%2$d created: %3$s.', 'arraysubs'),
                esc_html($entity_label),
                $post_id,
                esc_html($product_label)
            ),
            'private',
            $actor_id > 0 ? $actor_id : 0,
            [],
            'product'
        );
    }

    /**
     * Log WooCommerce product edits with changed field labels.
     *
     * @param \WC_Product $product Product object before save.
     * @return void
     */
    public function logProductAdminChanges($product): void
    {
        if (!$product instanceof \WC_Product) {
            return;
        }

        $audit_changes = $this->getChangedProductAuditChanges($product);
        $changed_fields = array_map(static fn(array $change): string => $change['label'], $audit_changes);

        if (empty($changed_fields) || !function_exists('arraysubs_add_global_note')) {
            return;
        }

        $actor_id     = get_current_user_id();
        $product_name = $this->getProductAuditNameFromObject($product);

        $this->bufferProductChanges(
            $product->get_id(),
            $product_name,
            $actor_id,
            $audit_changes
        );
    }

    /**
     * Log WooCommerce variation edits with changed field labels.
     *
     * @param \WC_Product_Variation $variation Variation object before save.
     * @param int                   $loop      Variation loop index.
     * @return void
     */
    public function logVariationAdminChanges($variation, int $loop): void
    {
        if (!$variation instanceof \WC_Product_Variation) {
            return;
        }

        $audit_changes = $this->getChangedProductAuditChanges($variation);
        $changed_fields = array_map(static fn(array $change): string => $change['label'], $audit_changes);

        if (empty($changed_fields) || !function_exists('arraysubs_add_global_note')) {
            return;
        }

        $actor_id       = get_current_user_id();
        $variation_name = $this->getProductAuditNameFromObject($variation);

        $this->bufferProductChanges(
            $variation->get_id(),
            $variation_name,
            $actor_id,
            $audit_changes
        );
    }

    /**
     * Cache product data when subscription is created from order
     *
     * @param int            $subscription_id Subscription post ID.
     * @param int            $order_id        Order ID.
     * @param \WC_Order_Item $item            Order item.
     * @return void
     */
    public function cacheProductData(int $subscription_id, int $order_id, $item): void
    {
        // Validate subscription
        $subscription = get_post($subscription_id);
        if (!$subscription || $subscription->post_type !== 'arraysubs_data') {
            return;
        }

        // Check if cache already exists (prevent duplicate caching)
        $existing_cache = get_post_meta($subscription_id, '_cached_product_data', true);
        if (!empty($existing_cache)) {
            return;
        }

        // Get product IDs from order item
        $product_id = 0;
        $variation_id = 0;

        if (is_callable([$item, 'get_product_id'])) {
            $product_id = $item->get_product_id();
        }
        if (is_callable([$item, 'get_variation_id'])) {
            $variation_id = $item->get_variation_id();
        }

        if (empty($product_id)) {
            return;
        }

        $actual_id = $variation_id > 0 ? $variation_id : $product_id;
        $this->storeProductCache($subscription_id, $actual_id, $product_id, $variation_id);
    }

    /**
     * Maybe cache product data when subscription is created directly
     *
     * @param int      $subscription_id Subscription post ID.
     * @param \WP_Post $post            Subscription post object.
     * @return void
     */
    public function maybeCacheProductData(int $subscription_id, \WP_Post $post): void
    {
        // Validate post type
        if ($post->post_type !== 'arraysubs_data') {
            return;
        }

        // Check if cache already exists
        $cached_name = get_post_meta($subscription_id, '_cached_product_name', true);
        if (!empty($cached_name)) {
            return;
        }

        $product_id = absint(get_post_meta($subscription_id, '_product_id', true));
        $variation_id = absint(get_post_meta($subscription_id, '_variation_id', true));

        if (empty($product_id)) {
            return;
        }

        $actual_id = $variation_id > 0 ? $variation_id : $product_id;
        $this->storeProductCache($subscription_id, $actual_id, $product_id, $variation_id);
    }

    /**
     * Store product cache on subscription
     *
     * @param int $subscription_id Subscription post ID.
     * @param int $actual_id       Actual product ID (variation or simple).
     * @param int $product_id      Parent product ID.
     * @param int $variation_id    Variation ID (0 if simple product).
     * @return void
     */
    private function storeProductCache(int $subscription_id, int $actual_id, int $product_id, int $variation_id = 0): void
    {
        // Validate product exists
        if (!function_exists('wc_get_product')) {
            return;
        }

        $product = wc_get_product($actual_id);
        if (!$product) {
            return;
        }

        // Store basic product info
        $product_name = $product->get_name();
        $product_sku = $product->get_sku();

        update_post_meta($subscription_id, '_cached_product_name', sanitize_text_field($product_name));
        update_post_meta($subscription_id, '_cached_product_sku', sanitize_text_field($product_sku));

        // Build comprehensive product data for reference
        $product_data = [
            'id'            => $actual_id,
            'parent_id'     => $product_id,
            'variation_id'  => $variation_id,
            'name'          => $product_name,
            'sku'           => $product_sku,
            'type'          => $product->get_type(),
            'price'         => $product->get_price(),
            'regular_price' => $product->get_regular_price(),
            'sale_price'    => $product->get_sale_price(),
            'virtual'       => $product->is_virtual(),
            'downloadable'  => $product->is_downloadable(),
            'cached_at'     => current_time('mysql', true),
        ];

        // For variations, store attributes
        if ($product->is_type('variation') && method_exists($product, 'get_attributes')) {
            $product_data['attributes'] = $product->get_attributes();
        }

        // Store as JSON
        $json_data = wp_json_encode($product_data);
        if ($json_data !== false) {
            update_post_meta($subscription_id, '_cached_product_data', $json_data);
        }
    }

    /**
     * Handle product being permanently deleted
     *
     * @param int      $post_id Post ID.
     * @param \WP_Post $post    Post object.
     * @return void
     */
    public function onProductDeleting(int $post_id, \WP_Post $post): void
    {
        // Prevent recursive calls
        if (self::$processing_product) {
            return;
        }

        if ($post->post_type !== 'product' && $post->post_type !== 'product_variation') {
            return;
        }

        self::$processing_product = true;

        try {
            $this->handleProductRemoval($post_id, 'deleted');
        } finally {
            self::$processing_product = false;
        }
    }

    /**
     * Handle product being trashed
     *
     * @param int $post_id Post ID.
     * @return void
     */
    public function onProductTrashing(int $post_id): void
    {
        // Prevent recursive calls
        if (self::$processing_product) {
            return;
        }

        $post = get_post($post_id);
        if (!$post || ($post->post_type !== 'product' && $post->post_type !== 'product_variation')) {
            return;
        }

        self::$processing_product = true;

        try {
            $this->handleProductRemoval($post_id, 'trashed');
        } finally {
            self::$processing_product = false;
        }
    }

    /**
     * Handle product removal (trash or delete)
     *
     * @param int    $product_id Product/variation ID.
     * @param string $action     'deleted' or 'trashed'.
     * @return void
     */
    private function handleProductRemoval(int $product_id, string $action): void
    {
        $post = get_post($product_id);

        // Get product name before it's deleted (for logging)
        $product_name = '';
        if (function_exists('wc_get_product')) {
            $product = wc_get_product($product_id);
            $product_name = $product ? $product->get_name() : sprintf(
                /* translators: %d: product ID number */
                __('Product #%d', 'arraysubs'),
                $product_id
            );
        } else {
            $product_name = sprintf(
                /* translators: %d: product ID number */
                __('Product #%d', 'arraysubs'),
                $product_id
            );
        }

        if (function_exists('arraysubs_add_global_note')) {
            $entity_label = ($post instanceof \WP_Post && $post->post_type === 'product_variation')
                ? __('Product variation', 'arraysubs')
                : __('Product', 'arraysubs');
            $actor_id     = get_current_user_id();

            arraysubs_add_global_note(
                sprintf(
                    /* translators: 1: entity label, 2: product ID, 3: action text, 4: product name. */
                    __('%1$s #%2$d %3$s: %4$s.', 'arraysubs'),
                    esc_html($entity_label),
                    $product_id,
                    esc_html($action === 'deleted' ? __('deleted', 'arraysubs') : __('moved to trash', 'arraysubs')),
                    esc_html($product_name)
                ),
                'private',
                $actor_id > 0 ? $actor_id : 0,
                [],
                'product'
            );
        }

        // Find subscriptions using this product
        $affected_subscriptions = $this->getSubscriptionsForProduct($product_id);

        if (empty($affected_subscriptions)) {
            return;
        }

        foreach ($affected_subscriptions as $subscription_id) {
            // Ensure we have the product cached before marking as deleted
            $this->ensureProductCached($subscription_id, $product_id);

            // Mark product as deleted on subscription
            update_post_meta($subscription_id, '_product_deleted', 'yes');
            update_post_meta($subscription_id, '_product_deleted_date', current_time('mysql', true));
            update_post_meta($subscription_id, '_product_deleted_action', sanitize_text_field($action));

            // Add subscription note
            if (function_exists('arraysubs_add_subscription_note')) {
                $note = sprintf(
                    /* translators: 1: action (deleted/trashed), 2: product name */
                    __('Product %1$s: "%2$s". Subscription will continue using cached product data.', 'arraysubs'),
                    $action === 'deleted' ? __('permanently deleted', 'arraysubs') : __('moved to trash', 'arraysubs'),
                    esc_html($product_name)
                );

                arraysubs_add_subscription_note($subscription_id, $note, 'private', 0, [], 'product');
            }

            /**
             * Fires when a product used by a subscription is deleted/trashed
             *
             * @param int    $subscription_id Subscription ID.
             * @param int    $product_id      Product ID.
             * @param string $action          'deleted' or 'trashed'.
             */
            do_action('arraysubs_product_removed', $subscription_id, $product_id, $action);
        }

        // Store transient for admin notice
        $notice_data = get_transient('arraysubs_product_deleted_notice');
        if (!is_array($notice_data)) {
            $notice_data = [];
        }
        $notice_data[] = [
            'product_id'    => $product_id,
            'product_name'  => $product_name,
            'action'        => $action,
            'subscriptions' => $affected_subscriptions,
            'time'          => current_time('mysql', true),
        ];
        set_transient('arraysubs_product_deleted_notice', $notice_data, HOUR_IN_SECONDS);
    }

    /**
     * Build a readable product label for audit entries.
     *
     * @param int      $product_id Product post ID.
     * @param \WP_Post $post      Product post object.
     * @return string
     */
    private function getProductAuditLabel(int $product_id, \WP_Post $post): string
    {
        if (function_exists('wc_get_product')) {
            $product = wc_get_product($product_id);

            if ($product) {
                return $product->get_name();
            }
        }

        if (!empty($post->post_title)) {
            return $post->post_title;
        }

        return sprintf(
            /* translators: %d: product ID number */
            __('Product #%d', 'arraysubs'),
            $product_id
        );
    }

    /**
     * Get a readable product name from a WC product object.
     *
     * @param \WC_Product $product Product object.
     * @return string
     */
    private function getProductAuditNameFromObject(\WC_Product $product): string
    {
        $name = $product->get_name();

        if (!empty($name)) {
            return $name;
        }

        return sprintf(
            /* translators: %d: product ID number */
            __('Product #%d', 'arraysubs'),
            $product->get_id()
        );
    }

    /**
     * Auditable product property labels.
     *
     * @return array<string, string>
     */
    private static function getAuditableProductProps(): array
    {
        return [
            'name'               => __('name', 'arraysubs'),
            'slug'               => __('slug', 'arraysubs'),
            'status'             => __('status', 'arraysubs'),
            'catalog_visibility' => __('catalog visibility', 'arraysubs'),
            'featured'           => __('featured flag', 'arraysubs'),
            'sku'                => __('SKU', 'arraysubs'),
            'description'        => __('description', 'arraysubs'),
            'short_description'  => __('short description', 'arraysubs'),
            'regular_price'      => __('regular price', 'arraysubs'),
            'sale_price'         => __('sale price', 'arraysubs'),
            'date_on_sale_from'  => __('sale start date', 'arraysubs'),
            'date_on_sale_to'    => __('sale end date', 'arraysubs'),
            'tax_status'         => __('tax status', 'arraysubs'),
            'tax_class'          => __('tax class', 'arraysubs'),
            'manage_stock'       => __('manage stock', 'arraysubs'),
            'stock_quantity'     => __('stock quantity', 'arraysubs'),
            'stock_status'       => __('stock status', 'arraysubs'),
            'backorders'         => __('backorders', 'arraysubs'),
            'sold_individually'  => __('sold individually', 'arraysubs'),
            'weight'             => __('weight', 'arraysubs'),
            'length'             => __('length', 'arraysubs'),
            'width'              => __('width', 'arraysubs'),
            'height'             => __('height', 'arraysubs'),
            'shipping_class_id'  => __('shipping class', 'arraysubs'),
            'menu_order'         => __('menu order', 'arraysubs'),
            'reviews_allowed'    => __('reviews setting', 'arraysubs'),
            'purchase_note'      => __('purchase note', 'arraysubs'),
            'virtual'            => __('virtual flag', 'arraysubs'),
            'downloadable'       => __('downloadable flag', 'arraysubs'),
            'attributes'         => __('attributes', 'arraysubs'),
            'default_attributes' => __('default attributes', 'arraysubs'),
            'image_id'           => __('image', 'arraysubs'),
            'gallery_image_ids'  => __('gallery images', 'arraysubs'),
        ];
    }

    /**
     * Build structured product audit changes by comparing the pre-save snapshot
     * against the current (new) product values.
     *
     * Falls back to WC's `get_changes()` diff when no pre-save snapshot was captured
     * (e.g. programmatic saves outside the admin).
     *
     * @param \WC_Product $product Product object with pending changes.
     * @return array<int, array<string, string>>
     */
    private function getChangedProductAuditChanges(\WC_Product $product): array
    {
        $product_id = $product->get_id();
        $labels     = self::getAuditableProductProps();
        $snapshot   = self::$pre_save_snapshots[$product_id] ?? null;

        if ($snapshot !== null) {
            return $this->diffSnapshotAgainstProduct($snapshot, $product, $labels);
        }

        return $this->diffUsingWcChanges($product, $labels);
    }

    /**
     * Diff the captured pre-save snapshot against the new product values.
     *
     * @param array<string, string> $snapshot Pre-save formatted values.
     * @param \WC_Product           $product  Product with new values.
     * @param array<string, string> $labels   Property labels.
     * @return array<int, array<string, string>>
     */
    private function diffSnapshotAgainstProduct(array $snapshot, \WC_Product $product, array $labels): array
    {
        $changed = [];

        foreach ($labels as $key => $label) {
            $old_value = $snapshot[$key] ?? '—';
            $new_value = $this->getProductAuditPropValue($product, $key);

            if ($old_value === $new_value) {
                continue;
            }

            $changed[] = arraysubs_create_audit_change_entry($label, $old_value, $new_value);
        }

        return $changed;
    }

    /**
     * Fallback diff using WC's internal get_changes() for programmatic saves.
     *
     * @param \WC_Product           $product Product with pending changes.
     * @param array<string, string> $labels  Property labels.
     * @return array<int, array<string, string>>
     */
    private function diffUsingWcChanges(\WC_Product $product, array $labels): array
    {
        if (!method_exists($product, 'get_changes')) {
            return [];
        }

        $changes = $product->get_changes();

        if (empty($changes) || !is_array($changes)) {
            return [];
        }

        $persisted_product = function_exists('wc_get_product') && $product->get_id() > 0
            ? wc_get_product($product->get_id())
            : null;

        $changed = [];

        foreach (array_keys($changes) as $key) {
            if ($key === 'meta_data' || !isset($labels[$key])) {
                continue;
            }

            $old_value = $persisted_product instanceof \WC_Product
                ? $this->getProductAuditPropValue($persisted_product, $key)
                : '—';
            $new_value = $this->getProductAuditPropValue($product, $key);

            if ($old_value === $new_value) {
                continue;
            }

            $changed[] = arraysubs_create_audit_change_entry($labels[$key], $old_value, $new_value);
        }

        return $changed;
    }

    /**
     * Format a product property for audit output.
     *
     * @param \WC_Product $product Product object.
     * @param string      $key     Product property key.
     * @return string
     */
    private function getProductAuditPropValue(\WC_Product $product, string $key): string
    {
        switch ($key) {
            case 'featured':
            case 'manage_stock':
            case 'sold_individually':
            case 'reviews_allowed':
            case 'virtual':
            case 'downloadable':
                $method = 'get_' . $key;
                return method_exists($product, $method)
                    ? arraysubs_format_audit_value((bool) $product->{$method}())
                    : '—';

            case 'regular_price':
            case 'sale_price':
                $method = 'get_' . $key;
                return method_exists($product, $method)
                    ? arraysubs_format_audit_value($product->{$method}(), 'money')
                    : '—';

            case 'date_on_sale_from':
            case 'date_on_sale_to':
                $method = 'get_' . $key;
                $date   = method_exists($product, $method) ? $product->{$method}() : null;

                return $date instanceof \WC_DateTime
                    ? arraysubs_format_timestamp_local($date->getTimestamp(), get_option('date_format') . ' ' . get_option('time_format'))
                    : '—';

            case 'shipping_class_id':
                $shipping_class_id = method_exists($product, 'get_shipping_class_id') ? absint($product->get_shipping_class_id()) : 0;

                if ($shipping_class_id <= 0) {
                    return '—';
                }

                $shipping_class = get_term($shipping_class_id, 'product_shipping_class');

                return ($shipping_class instanceof \WP_Term && !is_wp_error($shipping_class))
                    ? sanitize_text_field($shipping_class->name)
                    : '—';

            case 'stock_quantity':
                if (method_exists($product, 'get_manage_stock') && ! $product->get_manage_stock()) {
                    return '—';
                }

                return arraysubs_format_audit_value($product->get_stock_quantity());

            case 'attributes':
                return $this->formatProductAttributesForAudit($product->get_attributes());

            case 'default_attributes':
                return arraysubs_format_audit_value($product->get_default_attributes());

            case 'image_id':
                return $this->formatAttachmentReferenceForAudit($product->get_image_id());

            case 'gallery_image_ids':
                $image_refs = [];

                foreach ($product->get_gallery_image_ids() as $image_id) {
                    $image_refs[] = $this->formatAttachmentReferenceForAudit((int) $image_id);
                }

                return !empty($image_refs) ? implode(', ', array_unique($image_refs)) : '—';
        }

        $method = 'get_' . $key;

        if (!method_exists($product, $method)) {
            return '—';
        }

        return arraysubs_format_audit_value($product->{$method}());
    }

    /**
     * Format product attributes for audit output.
     *
     * @param array $attributes Product attributes.
     * @return string
     */
    private function formatProductAttributesForAudit(array $attributes): string
    {
        if (empty($attributes)) {
            return '—';
        }

        $formatted = [];

        foreach ($attributes as $attribute_key => $attribute) {
            if ($attribute instanceof \WC_Product_Attribute) {
                $name = $attribute->get_name();
                $options = $attribute->get_options();

                $formatted[] = sanitize_text_field($name) . ': ' . implode(', ', array_map('strval', $options));
                continue;
            }

            if (is_array($attribute)) {
                $formatted[] = sanitize_text_field((string) $attribute_key) . ': ' . wp_json_encode($attribute);
                continue;
            }

            $formatted[] = sanitize_text_field((string) $attribute_key) . ': ' . sanitize_text_field((string) $attribute);
        }

        return !empty($formatted) ? implode(' | ', $formatted) : '—';
    }

    /**
     * Format an attachment ID as a readable audit value.
     *
     * @param int|string $attachment_id Attachment ID.
     * @return string
     */
    private function formatAttachmentReferenceForAudit(int|string $attachment_id): string
    {
        $attachment_id = absint($attachment_id);

        if ($attachment_id <= 0) {
            return '—';
        }

        $title = get_the_title($attachment_id);

        if (is_string($title) && $title !== '') {
            return sprintf(
                /* translators: 1: media title, 2: attachment ID. */
                __('%1$s (#%2$d)', 'arraysubs'),
                sanitize_text_field($title),
                $attachment_id
            );
        }

        return (string) $attachment_id;
    }

    /**
     * Ensure product is cached on subscription before deletion
     *
     * @param int $subscription_id Subscription ID.
     * @param int $product_id      Product ID being deleted.
     * @return void
     */
    private function ensureProductCached(int $subscription_id, int $product_id): void
    {
        $cached_data = get_post_meta($subscription_id, '_cached_product_data', true);
        if (!empty($cached_data)) {
            return;
        }

        // Get product before it's fully deleted
        if (!function_exists('wc_get_product')) {
            return;
        }

        $product = wc_get_product($product_id);
        if (!$product) {
            return;
        }

        $product_id_stored = absint(get_post_meta($subscription_id, '_product_id', true));
        $variation_id = absint(get_post_meta($subscription_id, '_variation_id', true));

        $this->storeProductCache($subscription_id, $product_id, $product_id_stored, $variation_id);
    }

    /**
     * Handle product being restored from trash
     *
     * @param int $post_id Post ID.
     * @return void
     */
    public function onProductUntrashing(int $post_id): void
    {
        $post = get_post($post_id);
        if (!$post || ($post->post_type !== 'product' && $post->post_type !== 'product_variation')) {
            return;
        }

        // Find subscriptions that had this product marked as deleted
        $affected_subscriptions = $this->getSubscriptionsForProduct($post_id);

        if (empty($affected_subscriptions)) {
            return;
        }

        // Get product name
        $product_name = '';
        if (function_exists('wc_get_product')) {
            $product = wc_get_product($post_id);
            /* translators: %d: product ID number */
            $product_name = $product ? $product->get_name() : sprintf(__('Product #%d', 'arraysubs'), $post_id);
        } else {
            /* translators: %d: product ID number */
            $product_name = sprintf(__('Product #%d', 'arraysubs'), $post_id);
        }

        foreach ($affected_subscriptions as $subscription_id) {
            $was_deleted = get_post_meta($subscription_id, '_product_deleted', true);
            if ($was_deleted !== 'yes') {
                continue;
            }

            // Clear deletion flags
            delete_post_meta($subscription_id, '_product_deleted');
            delete_post_meta($subscription_id, '_product_deleted_date');
            delete_post_meta($subscription_id, '_product_deleted_action');

            // Add subscription note
            if (function_exists('arraysubs_add_subscription_note')) {
                /* translators: %s: name or ID of the product that was restored */
                $note = sprintf(
                    /* translators: %s: name or ID of the product that was restored */
                    __('Product restored: "%s". Subscription product link is now active again.', 'arraysubs'),
                    esc_html($product_name)
                );

                arraysubs_add_subscription_note($subscription_id, $note, 'private', 0, [], 'product');
            }

            /**
             * Fires when a product used by a subscription is restored from trash
             *
             * @param int $subscription_id Subscription ID.
             * @param int $product_id      Product ID.
             */
            do_action('arraysubs_product_restored', $subscription_id, $post_id);
        }
    }

    /**
     * Get subscriptions using a specific product
     *
     * @param int $product_id Product or variation ID.
     * @return array Array of subscription IDs.
     */
    private function getSubscriptionsForProduct(int $product_id): array
    {
        // Query subscriptions with this product
        $args = [
            'post_type'      => 'arraysubs_data',
            'post_status'    => [
                'arraysubs-active',
                'arraysubs-on-hold',
                'arraysubs-trial',
                'arraysubs-pending',
            ],
            'posts_per_page' => -1,
            'fields'         => 'ids',
            'meta_query'     => [
                'relation' => 'OR',
                [
                    'key'     => '_product_id',
                    'value'   => $product_id,
                    'compare' => '=',
                    'type'    => 'NUMERIC',
                ],
                [
                    'key'     => '_variation_id',
                    'value'   => $product_id,
                    'compare' => '=',
                    'type'    => 'NUMERIC',
                ],
            ],
            'no_found_rows'          => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ];

        $subscriptions = get_posts($args);

        return is_array($subscriptions) ? array_map('absint', $subscriptions) : [];
    }

    /**
     * Show admin notice when product was deleted with active subscriptions
     *
     * @return void
     */
    public function showProductDeletedNotice(): void
    {
        // Only show on relevant admin pages
        $screen = get_current_screen();
        if (!$screen || !in_array($screen->base, ['edit', 'post', 'toplevel_page_arraysubs'], true)) {
            return;
        }

        $notice_data = get_transient('arraysubs_product_deleted_notice');
        if (empty($notice_data) || !is_array($notice_data)) {
            return;
        }

        // Clear the transient
        delete_transient('arraysubs_product_deleted_notice');

        foreach ($notice_data as $notice) {
            if (!isset($notice['subscriptions']) || !is_array($notice['subscriptions'])) {
                continue;
            }

            $count = count($notice['subscriptions']);
            $action_text = isset($notice['action']) && $notice['action'] === 'deleted'
                ? __('permanently deleted', 'arraysubs')
                : __('moved to trash', 'arraysubs');
            /* translators: %d: product ID number */
            $product_name = isset($notice['product_name']) ? $notice['product_name'] : sprintf(/* translators: %d: product ID number */__('Product #%d', 'arraysubs'), $notice['product_id']);

            echo '<div class="notice notice-warning is-dismissible">';
            echo '<p><strong>' . esc_html__('ArraySubs:', 'arraysubs') . '</strong> ';
            /* translators: 1: product name, 2: action (deleted/trashed), 3: number of affected subscriptions */
            echo esc_html(sprintf(
                /* translators: 1: product name, 2: action (deleted/trashed), 3: number of affected subscriptions */
                _n(
                    '"%1$s" was %2$s. %3$d active subscription uses this product and will continue with cached product data.',
                    '"%1$s" was %2$s. %3$d active subscriptions use this product and will continue with cached product data.',
                    $count,
                    'arraysubs'
                ),
                $product_name,
                $action_text,
                $count
            ));

            // Add link to subscriptions
            $admin_url = admin_url('admin.php?page=arraysubs#/subscriptions');
            echo ' <a href="' . esc_url($admin_url) . '">' . esc_html__('View Subscriptions', 'arraysubs') . '</a>';
            echo '</p></div>';
        }
    }

    /**
     * Show warning in WooCommerce product edit if product has active subscriptions
     *
     * @return void
     */
    public function showSubscriptionWarning(): void
    {
        global $post;

        if (!$post || $post->post_type !== 'product') {
            return;
        }

        // Check if this is a subscription product
        $is_subscription = get_post_meta($post->ID, '_is_subscription', true);
        if ($is_subscription !== 'yes') {
            return;
        }

        // Count active subscriptions for this product
        $subscriptions = $this->getSubscriptionsForProduct($post->ID);

        // Also check variations if this is a variable product
        if (function_exists('wc_get_product')) {
            $product = wc_get_product($post->ID);
            if ($product && $product->is_type('variable') && method_exists($product, 'get_children')) {
                $variation_ids = $product->get_children();
                if (is_array($variation_ids)) {
                    foreach ($variation_ids as $variation_id) {
                        $var_subscriptions = $this->getSubscriptionsForProduct($variation_id);
                        $subscriptions = array_merge($subscriptions, $var_subscriptions);
                    }
                    $subscriptions = array_unique($subscriptions);
                }
            }
        }

        if (empty($subscriptions)) {
            return;
        }

        $count = count($subscriptions);

        echo '<div class="options_group">';
        echo '<p class="form-field" style="background: #fff3cd; padding: 10px!important; border-left: 4px solid #ffc107; margin: 10px!important;">';
        echo '<strong>' . esc_html__('⚠️ Active Subscriptions', 'arraysubs') . '</strong><br>';
        echo esc_html(sprintf(
            /* translators: %d: number of subscriptions */
            _n(
                'This product has %d active subscription. Deleting this product will NOT affect the subscription - it will continue using cached product data.',
                'This product has %d active subscriptions. Deleting this product will NOT affect subscriptions - they will continue using cached product data.',
                $count,
                'arraysubs'
            ),
            $count
        ));
        echo '</p></div>';
    }

    /**
     * Filter to get product name (with cache fallback)
     *
     * @param string $name            Current product name.
     * @param int    $subscription_id Subscription ID.
     * @return string Product name.
     */
    public function filterProductName(string $name, int $subscription_id): string
    {
        $result = self::getProductName($subscription_id);
        return !empty($result) ? $result : $name;
    }

    /**
     * Get product name for subscription (uses cache if product deleted)
     *
     * Static method for external use.
     *
     * @param int $subscription_id Subscription post ID.
     * @return string Product name.
     */
    public static function getProductName(int $subscription_id): string
    {
        $product_deleted = get_post_meta($subscription_id, '_product_deleted', true);

        // If product is deleted, use cached name
        if ($product_deleted === 'yes') {
            $cached_name = get_post_meta($subscription_id, '_cached_product_name', true);
            if (!empty($cached_name)) {
                return $cached_name . ' ' . __('(deleted)', 'arraysubs');
            }
        }

        // Try to get from actual product
        $variation_id = absint(get_post_meta($subscription_id, '_variation_id', true));
        $product_id = absint(get_post_meta($subscription_id, '_product_id', true));
        $actual_id = $variation_id > 0 ? $variation_id : $product_id;

        if (!empty($actual_id) && function_exists('wc_get_product')) {
            $product = wc_get_product($actual_id);
            if ($product) {
                return $product->get_name();
            }
        }

        // Fallback to cached name
        $cached_name = get_post_meta($subscription_id, '_cached_product_name', true);
        if (!empty($cached_name)) {
            return $cached_name;
        }

        return __('Unknown Product', 'arraysubs');
    }

    /**
     * Get full cached product data for subscription
     *
     * @param int $subscription_id Subscription post ID.
     * @return array|null Product data or null if not cached.
     */
    public static function getCachedProductData(int $subscription_id): ?array
    {
        $cached_json = get_post_meta($subscription_id, '_cached_product_data', true);
        if (empty($cached_json)) {
            return null;
        }

        $data = json_decode($cached_json, true);
        return is_array($data) ? $data : null;
    }

    /**
     * Check if subscription's product was deleted
     *
     * @param int $subscription_id Subscription post ID.
     * @return bool True if product was deleted.
     */
    public static function isProductDeleted(int $subscription_id): bool
    {
        return get_post_meta($subscription_id, '_product_deleted', true) === 'yes';
    }

    /**
     * Buffer product audit changes so multiple saves in the same request
     * are merged into a single note.
     *
     * @param int    $product_id   Product or variation ID.
     * @param string $product_name Product display name.
     * @param int    $actor_id     User who made the change.
     * @param array  $changes      Audit change entries.
     * @return void
     */
    private function bufferProductChanges(int $product_id, string $product_name, int $actor_id, array $changes): void
    {
        if (isset(self::$product_change_buffer[$product_id])) {
            self::$product_change_buffer[$product_id]['changes'] = array_merge(
                self::$product_change_buffer[$product_id]['changes'],
                $changes
            );
        } else {
            self::$product_change_buffer[$product_id] = [
                'name'     => $product_name,
                'actor_id' => $actor_id,
                'changes'  => $changes,
            ];
        }

        if (!self::$flush_registered) {
            self::$flush_registered = true;
            add_action('shutdown', [__CLASS__, 'flushProductChangeBuffer'], 5);
        }
    }

    /**
     * Flush the buffered product changes as a single note per product.
     *
     * @return void
     */
    public static function flushProductChangeBuffer(): void
    {
        if (empty(self::$product_change_buffer) || !function_exists('arraysubs_add_global_note')) {
            return;
        }

        foreach (self::$product_change_buffer as $product_id => $entry) {
            $changed_fields = array_map(
                static fn(array $change): string => $change['label'],
                $entry['changes']
            );

            $changed_fields = array_unique($changed_fields);

            if (empty($changed_fields)) {
                continue;
            }

            arraysubs_add_global_note(
                sprintf(
                    /* translators: 1: product ID, 2: product name, 3: changed field labels. */
                    __('Product #%1$d updated: %2$s — changed %3$s.', 'arraysubs'),
                    $product_id,
                    esc_html($entry['name']),
                    esc_html(implode(', ', $changed_fields))
                ),
                'private',
                $entry['actor_id'] > 0 ? $entry['actor_id'] : 0,
                $entry['changes'],
                'product'
            );
        }

        self::$product_change_buffer = [];
    }
}
