<?php
/**
 * Centralised options layer.
 *
 * @package SkyEmagSync
 */

namespace SkyEmagSync;

defined( 'ABSPATH' ) || exit;

final class Options {

    // ── Option key constants ─────────────────────────────────────────────
    const MASTER_ENABLED   = 'skyemag_master_enabled';
    const GLOBAL_MARGIN     = 'skyemag_global_margin';
    const GLOBAL_MIN_MARGIN = 'skyemag_global_min_margin';
    const GLOBAL_MAX_MARGIN = 'skyemag_global_max_margin';
    const GLOBAL_STOCK_PCT  = 'skyemag_global_stock_percent';
    const MIN_WC_STOCK      = 'skyemag_min_wc_stock';
    const ROUNDING_RULE    = 'skyemag_rounding_rule';
    const SYNC_SCHEDULE    = 'skyemag_sync_schedule';
    const API_CREDENTIALS  = 'skyemag_api_credentials';
    const VAT_CACHE        = 'skyemag_vat_cache';       // array: vat_rate(string) => vat_id(int)
    const BOOTSTRAP_DONE   = 'skyemag_bootstrap_done'; // bool
    const BOOTSTRAP_MODE   = 'skyemag_bootstrap_mode'; // 'global_margin' | 'preserve_emag_prices'

    // Valid Bootstrap pricing strategies. global_margin keeps the historical
    // behaviour (overwrite eMAG sale_price with WC × global margin on first
    // sync). preserve_emag_prices reads the current eMAG sale_price during
    // Bootstrap and back-calculates an implied per-product margin so the
    // existing marketplace prices stay intact until the seller changes them.
    const BOOTSTRAP_MODES         = [ 'global_margin', 'preserve_emag_prices' ];
    const BOOTSTRAP_MODE_DEFAULT  = 'global_margin';
    const LOG_RETENTION_DAYS  = 'skyemag_log_retention_days'; // int days
    const DELETE_ON_UNINSTALL = 'skyemag_delete_on_uninstall'; // 'yes' | ''
    const AUTO_SANITIZE_CHARS = 'skyemag_auto_sanitize_chars'; // bool — Pro only

    // Sync exclusion filters (Free). Products matching ANY criterion are skipped.
    const SYNC_MIN_PRICE_EX_VAT = 'skyemag_sync_min_price_ex_vat'; // float lei, 0 = disabled
    const SYNC_MAX_WEIGHT_KG    = 'skyemag_sync_max_weight_kg';    // float kg,  0 = disabled
    const SYNC_SKIP_SKU_PATTERN = 'skyemag_sync_skip_sku_pattern'; // regex,     '' = disabled
    const SYNC_SKIP_ZERO_STOCK  = 'skyemag_sync_skip_zero_stock';  // bool, off  = send 0 stock

    // Price source toggle (Free). When true, OfferPayload uses the WC regular
    // price instead of the active (sale-aware) price, so WC promos don't leak
    // into eMAG.
    const USE_REGULAR_PRICE = 'skyemag_use_regular_price';

    // Publish readiness: minimum main-image edge in pixels (Pro). 0 = disabled.
    const MIN_IMAGE_PIXELS         = 'skyemag_min_image_pixels';
    const MIN_IMAGE_PIXELS_DEFAULT = 600;

    // Notifications (Pro only). Email-only for now; Slack/Discord may follow later.
    const NOTIF_EMAIL_ENABLED           = 'skyemag_notif_email_enabled';
    const NOTIF_EMAIL_ADDRESS           = 'skyemag_notif_email_address';
    const NOTIF_EVENT_SYNC_ERRORS       = 'skyemag_notif_event_sync_errors';
    const NOTIF_EVENT_PRODUCT_BLOCKED   = 'skyemag_notif_event_product_blocked';
    const NOTIF_EVENT_COMPETITOR_ALERT  = 'skyemag_notif_event_competitor_alert';
    const NOTIF_EVENT_BB_DROP           = 'skyemag_notif_event_bb_drop';
    const NOTIF_COMPETITOR_THRESHOLD    = 'skyemag_notif_competitor_threshold'; // percent
    const NOTIF_COMPETITOR_THRESHOLD_DEFAULT = 5;
    const NOTIF_COMPETITOR_THRESHOLD_MIN     = 1;
    const NOTIF_COMPETITOR_THRESHOLD_MAX     = 50;

    // ── Order import option keys ─────────────────────────────────────────
    // eMAG → WC order import feature. All options default OFF. The
    // consuming code (Orders/OrderImporter, Admin/OrdersPage, etc.) ships
    // only in the Premium build via the @fs_premium_only directive, so on
    // a Free install these option getters return safe defaults but no
    // code path acts on them.
    const ORDER_IMPORT_ENABLED           = 'skyemag_order_import_enabled';
    const ORDER_FETCH_FREQUENCY          = 'skyemag_order_fetch_frequency';
    const ORDER_AUTO_DECREMENT_STOCK     = 'skyemag_order_auto_decrement_stock';
    const ORDER_IMPORT_CUSTOMER_AS_USER  = 'skyemag_order_import_customer_as_user';
    const ORDER_NOTIFY_ADMIN             = 'skyemag_order_notify_admin';
    const ORDER_STATUS_MAPPING           = 'skyemag_order_status_mapping';
    const ORDER_HISTORICAL_DAYS          = 'skyemag_order_historical_days';

    // Default eMAG → WC status map. Keys are eMAG status codes (string form
    // so the serialised array preserves 0 as a valid key). Values are bare
    // WC status slugs without the `wc-` prefix; WC_Order::update_status()
    // accepts either form.
    const ORDER_STATUS_DEFAULTS = [
        '1' => 'processing',  // eMAG: new
        '2' => 'processing',  // eMAG: in_progress
        '3' => 'processing',  // eMAG: prepared
        '4' => 'completed',   // eMAG: finalized
        '5' => 'refunded',    // eMAG: returned
        '0' => 'cancelled',   // eMAG: canceled
    ];

    const ORDER_HISTORICAL_DAYS_DEFAULT = 30;
    const ORDER_HISTORICAL_DAYS_MIN     = 1;
    const ORDER_HISTORICAL_DAYS_MAX     = 365;

    // Webhook callback infrastructure. eMAG Marketplace exposes per-seller
    // callback URLs in "Detalii tehnice → Setari"; the seller pastes our
    // REST endpoints there and eMAG hits them when an order is created or
    // cancelled. Querystring auth via ORDERS_CALLBACK_KEY (auto-generated
    // on first access). Both the importer and the manual single-order
    // import form respect ORDER_DRY_RUN — when ON the mapper logs what
    // would be created without writing a WC_Order. Default ON because the
    // live test client refused to UAT against a real WC database without
    // a dry-run safeguard.
    const ORDERS_CALLBACK_KEY = 'skyemag_orders_callback_key';
    const ORDER_DRY_RUN       = 'skyemag_order_dry_run';

    // ── AWB settings (Pro only) ──────────────────────────────────────────
    const AWB_DEFAULT_PRINT_FORMAT = 'skyemag_awb_default_print_format';
    const AWB_SENDER_NAME          = 'skyemag_awb_sender_name';
    const AWB_SENDER_CONTACT       = 'skyemag_awb_sender_contact';
    const AWB_SENDER_PHONE         = 'skyemag_awb_sender_phone';

	// ── Setup wizard state ───────────────────────────────────────────────
	// Tracks which step the user is on (1-6), whether the wizard has been
	// completed at least once, and whether the user dismissed it via Skip.
	// All three default to safe values — fresh installs see step 1, neither
	// completed nor skipped.
	const SETUP_STEP      = 'skyemag_setup_step';      // int 1..6
	const SETUP_COMPLETED = 'skyemag_setup_completed'; // bool
	const SETUP_SKIPPED   = 'skyemag_setup_skipped';   // bool

	// Transient flag set on activation, consumed by admin_init redirect.
	const SETUP_REDIRECT_TRANSIENT = 'skyemag_setup_redirect';

	// Total step counts for the indicator.
	const SETUP_STEP_COUNT_FREE = 5;
	const SETUP_STEP_COUNT_PRO  = 6;

    // ── Publish settings (Step 6) ────────────────────────────────────────
    const PUBLISH_WARRANTY      = 'skyemag_publish_warranty';       // int months
    const PUBLISH_RESUPPLY_DAYS = 'skyemag_publish_resupply_days';  // int days
    const PUBLISH_LIVE          = 'skyemag_publish_live';           // bool – false = dry run
    const PUBLISH_ID_OFFSET     = 'skyemag_publish_id_offset';      // int – added to WC post ID at publish

    // Field source settings — configurable meta keys with fallback order
    const BRAND_SOURCES         = 'skyemag_brand_sources';          // array of meta keys
    const EAN_SOURCES           = 'skyemag_ean_sources';            // array of meta keys

    // ── Valid enumerations ───────────────────────────────────────────────
    const ROUNDING_RULES = [ 'none', '2decimals', 'end99' ];
    const SCHEDULES      = [ '15min', '30min', '1h', '3h', '6h', '12h', 'daily' ];

    // Default schedule applied when no value has been saved yet.
    const DEFAULT_SCHEDULE = 'daily';

    const SCHEDULE_INTERVALS = [
        '15min' => 'skyemag_15min',
        '30min' => 'skyemag_30min',
        '1h'    => 'hourly',
        '3h'    => 'skyemag_3h',
        '6h'    => 'skyemag_6h',
        '12h'   => 'skyemag_12h',
        'daily' => 'daily',
    ];

    // Log retention bounds, in days. Applied identically on every install.
    const LOG_RETENTION_DEFAULT = 30;
    const LOG_RETENTION_MIN     = 7;
    const LOG_RETENTION_MAX     = 365;

    // ── Read / write ─────────────────────────────────────────────────────

    public static function get( string $key, $default = null ) {
        return get_option( $key, $default );
    }

    public static function set( string $key, $value ): void {
        update_option( $key, $value );
    }

    // ── Typed getters ────────────────────────────────────────────────────

    public static function is_master_enabled(): bool {
        return (bool) self::get( self::MASTER_ENABLED, false );
    }

    public static function global_margin(): float {
        return (float) self::get( self::GLOBAL_MARGIN, 0.0 );
    }

    public static function global_min_margin(): float {
        // Default: same as global_margin (mirrors the main margin)
        $val = self::get( self::GLOBAL_MIN_MARGIN, null );
        return ( $val !== null && $val !== '' ) ? (float) $val : self::global_margin();
    }

    public static function global_max_margin(): float {
        // Default: margin + 50 percentage points (e.g. margin=30 → max=80)
        $val = self::get( self::GLOBAL_MAX_MARGIN, null );
        return ( $val !== null && $val !== '' ) ? (float) $val : self::global_margin() + 50;
    }

    public static function global_stock_percent(): float {
        return (float) self::get( self::GLOBAL_STOCK_PCT, 50.0 );
    }

    public static function min_wc_stock(): int {
        $val = self::get( self::MIN_WC_STOCK, 0 );
        return max( 0, (int) $val );
    }

    public static function rounding_rule(): string {
        $val = (string) self::get( self::ROUNDING_RULE, '2decimals' );
        return in_array( $val, self::ROUNDING_RULES, true ) ? $val : '2decimals';
    }

    public static function sync_schedule(): string {
        $val = (string) self::get( self::SYNC_SCHEDULE, self::DEFAULT_SCHEDULE );
        return in_array( $val, self::SCHEDULES, true ) ? $val : self::DEFAULT_SCHEDULE;
    }

    /**
     * Log retention (in days). Configurable on every install within
     * the LOG_RETENTION_MIN..LOG_RETENTION_MAX bounds.
     */
    public static function log_retention_days(): int {
        $val = (int) self::get( self::LOG_RETENTION_DAYS, self::LOG_RETENTION_DEFAULT );
        return max( self::LOG_RETENTION_MIN, min( self::LOG_RETENTION_MAX, $val ) );
    }

    public static function delete_on_uninstall(): bool {
        return 'yes' === (string) self::get( self::DELETE_ON_UNINSTALL, '' );
    }

    // ── Sync exclusion filter getters ─────────────────────────────────────

    public static function sync_min_price_ex_vat(): float {
        return max( 0.0, (float) self::get( self::SYNC_MIN_PRICE_EX_VAT, 0 ) );
    }

    public static function sync_max_weight_kg(): float {
        return max( 0.0, (float) self::get( self::SYNC_MAX_WEIGHT_KG, 0 ) );
    }

    public static function sync_skip_sku_pattern(): string {
        return trim( (string) self::get( self::SYNC_SKIP_SKU_PATTERN, '' ) );
    }

    public static function sync_skip_zero_stock(): bool {
        return (bool) self::get( self::SYNC_SKIP_ZERO_STOCK, false );
    }

    /**
     * Whether OfferPayload should bypass WC sale prices and push the regular
     * price to eMAG instead. Default: OFF (WC effective price wins).
     */
    public static function use_regular_price(): bool {
        return (bool) self::get( self::USE_REGULAR_PRICE, false );
    }

    /**
     * Minimum required dimension (shorter edge) for a product's featured image
     * before it is considered publish-ready. 0 disables the check.
     */
    public static function min_image_pixels(): int {
        return max( 0, (int) self::get( self::MIN_IMAGE_PIXELS, self::MIN_IMAGE_PIXELS_DEFAULT ) );
    }

    /**
     * Whether publish payloads should auto-clean characteristic values
     * before sending to eMAG (extract numbers, trim text, normalize booleans).
     * Pro-only feature; default ON for premium installs.
     */
    public static function auto_sanitize_chars(): bool {
        return (bool) self::get( self::AUTO_SANITIZE_CHARS, true );
    }

    // ── Notifications helpers (Pro only) ─────────────────────────────────

    public static function notif_email_enabled(): bool {
        return (bool) self::get( self::NOTIF_EMAIL_ENABLED, false );
    }

    public static function notif_email_address(): string {
        $addr = (string) self::get( self::NOTIF_EMAIL_ADDRESS, '' );
        if ( '' === $addr ) {
            // Fall back to admin email so the field is never silently empty on first enable.
            $addr = (string) get_option( 'admin_email', '' );
        }
        return $addr;
    }

    public static function notif_event_enabled( string $event ): bool {
        $map = [
            'sync_errors'        => [ self::NOTIF_EVENT_SYNC_ERRORS,      true  ],
            'product_blocked'    => [ self::NOTIF_EVENT_PRODUCT_BLOCKED,  true  ],
            'competitor_alert'   => [ self::NOTIF_EVENT_COMPETITOR_ALERT, true  ],
            'bb_drop'            => [ self::NOTIF_EVENT_BB_DROP,          true  ],
            // Wired into the OrdersPage toggle so the seller has all order
            // settings in one place, instead of splitting between Orders +
            // Notifications pages. Default false.
            'new_order_imported' => [ self::ORDER_NOTIFY_ADMIN,           false ],
            // Same toggle covers cancellation notifications — the seller
            // wants to know when an eMAG order is cancelled, no separate
            // opt-in needed.
            'order_cancelled'    => [ self::ORDER_NOTIFY_ADMIN,           false ],
            'awb_issued'         => [ self::ORDER_NOTIFY_ADMIN,           false ],
        ];
        if ( ! isset( $map[ $event ] ) ) {
            return false;
        }
        list( $key, $default ) = $map[ $event ];
        return (bool) self::get( $key, $default );
    }

    public static function notif_competitor_threshold(): float {
        $val = (float) self::get( self::NOTIF_COMPETITOR_THRESHOLD, self::NOTIF_COMPETITOR_THRESHOLD_DEFAULT );
        if ( $val < self::NOTIF_COMPETITOR_THRESHOLD_MIN ) {
            $val = self::NOTIF_COMPETITOR_THRESHOLD_MIN;
        }
        if ( $val > self::NOTIF_COMPETITOR_THRESHOLD_MAX ) {
            $val = self::NOTIF_COMPETITOR_THRESHOLD_MAX;
        }
        return $val;
    }

    // ── Order import helpers (Pro only) ──────────────────────────────────

    public static function is_order_import_enabled(): bool {
        return (bool) self::get( self::ORDER_IMPORT_ENABLED, false );
    }

    public static function order_fetch_frequency(): string {
        $val = (string) self::get( self::ORDER_FETCH_FREQUENCY, '1h' );
        if ( ! in_array( $val, self::SCHEDULES, true ) ) {
            return '1h';
        }
        return $val;
    }

    /**
     * Auto-decrement WooCommerce stock when an eMAG order is imported.
     *
     * CRITICAL: defaults to OFF to avoid double-decrement when the seller
     * already syncs stock via SmartBill, Oblio, FGO or a similar invoicing
     * service that hooks WooCommerce on invoice issuance. Never flip this
     * default without explicit user opt-in.
     */
    public static function order_auto_decrement_stock(): bool {
        return (bool) self::get( self::ORDER_AUTO_DECREMENT_STOCK, false );
    }

    public static function order_import_customer_as_user(): bool {
        return (bool) self::get( self::ORDER_IMPORT_CUSTOMER_AS_USER, false );
    }

    public static function order_notify_admin(): bool {
        return (bool) self::get( self::ORDER_NOTIFY_ADMIN, false );
    }

    public static function awb_default_print_format(): string {
        $value = (string) self::get( self::AWB_DEFAULT_PRINT_FORMAT, 'A6' );
        $valid = [ 'A4', 'A5', 'A6', 'ZPL' ];
        return in_array( $value, $valid, true ) ? $value : 'A6';
    }

    public static function awb_sender_name(): string {
        $value = (string) self::get( self::AWB_SENDER_NAME, '' );
        return '' !== $value ? $value : (string) get_option( 'blogname', '' );
    }

    public static function awb_sender_contact(): string {
        $value = (string) self::get( self::AWB_SENDER_CONTACT, '' );
        if ( '' !== $value ) {
            return $value;
        }
        $user = wp_get_current_user();
        return ( $user && $user->display_name ) ? (string) $user->display_name : '';
    }

    public static function awb_sender_phone(): string {
        return (string) self::get( self::AWB_SENDER_PHONE, '' );
    }

    /**
     * Resolved eMAG → WC status mapping. Always returns all six eMAG codes;
     * stored values are validated against WC's current status registry and
     * fall back to ORDER_STATUS_DEFAULTS when missing or unknown.
     *
     * @return array<string,string>  keys: '0'..'5' (eMAG), values: WC slugs
     */
    public static function order_status_mapping(): array {
        $stored = self::get( self::ORDER_STATUS_MAPPING, [] );
        $stored = is_array( $stored ) ? $stored : [];

        // Valid WC order status slugs (without the wc- prefix).
        $valid = function_exists( 'wc_get_order_statuses' )
            ? array_keys( wc_get_order_statuses() )
            : [];
        $valid = array_map(
            static function ( $slug ) {
                return 0 === strpos( (string) $slug, 'wc-' ) ? substr( (string) $slug, 3 ) : (string) $slug;
            },
            $valid
        );

        $out = [];
        foreach ( self::ORDER_STATUS_DEFAULTS as $emag_code => $default_wc ) {
            $candidate = isset( $stored[ $emag_code ] ) ? (string) $stored[ $emag_code ] : '';
            if ( 0 === strpos( $candidate, 'wc-' ) ) {
                $candidate = substr( $candidate, 3 );
            }
            if ( '' === $candidate ) {
                $out[ $emag_code ] = $default_wc;
            } elseif ( ! empty( $valid ) && ! in_array( $candidate, $valid, true ) ) {
                $out[ $emag_code ] = $default_wc;
            } else {
                $out[ $emag_code ] = $candidate;
            }
        }
        return $out;
    }

    public static function order_historical_days(): int {
        $val = (int) self::get( self::ORDER_HISTORICAL_DAYS, self::ORDER_HISTORICAL_DAYS_DEFAULT );
        if ( $val < self::ORDER_HISTORICAL_DAYS_MIN ) {
            return self::ORDER_HISTORICAL_DAYS_MIN;
        }
        if ( $val > self::ORDER_HISTORICAL_DAYS_MAX ) {
            return self::ORDER_HISTORICAL_DAYS_MAX;
        }
        return $val;
    }

    /**
     * Webhook callback secret. Used in the order-callback REST URL the
     * seller pastes into eMAG → Detalii tehnice. Lazily generated on
     * first access so callers don't need a separate activation hook.
     * Use rotate_orders_callback_key() to invalidate it.
     */
    public static function orders_callback_key(): string {
        $val = (string) self::get( self::ORDERS_CALLBACK_KEY, '' );
        if ( '' === $val ) {
            $val = wp_generate_password( 32, false, false );
            self::set( self::ORDERS_CALLBACK_KEY, $val );
        }
        return $val;
    }

    public static function rotate_orders_callback_key(): string {
        $val = wp_generate_password( 32, false, false );
        self::set( self::ORDERS_CALLBACK_KEY, $val );
        return $val;
    }

    /**
     * Dry-run toggle for order import. When TRUE the mapper computes the
     * full WC_Order payload and writes a log entry but does NOT create
     * the post — used for UAT against a live WC database without
     * polluting wp_posts. Default ON for first-time installs (memory:
     * project_emag_orderimporter_dryrun).
     *
     * Stored as 'yes' / 'no' string rather than bool to dodge the
     * update_option no-op quirk: WP refuses to write the row when the
     * new value matches the implicit default false, which would leave
     * a freshly-unchecked checkbox reading back as the default true on
     * the next request.
     */
    public static function order_dry_run(): bool {
        $stored = (string) self::get( self::ORDER_DRY_RUN, 'yes' );
        return 'no' !== $stored;
    }

    public static function api_credentials(): array {
        $raw          = self::get( self::API_CREDENTIALS, [] );
        $stored_pwd   = (string) ( $raw['password'] ?? '' );
        $decrypted    = self::decrypt( $stored_pwd );

        // Auto-migration: if the stored value is a v1 (legacy static-IV)
        // ciphertext and we successfully decrypted it, re-encrypt under
        // the v2 random-IV scheme on the next persisted write. We do this
        // in-place here only for the read path that admins hit on every
        // Settings page load — guaranteed to migrate within seconds of
        // 3.5.0 going live without any user action.
        if (
            '' !== $decrypted
            && self::is_legacy_v1_ciphertext( $stored_pwd )
            && function_exists( 'openssl_encrypt' )
        ) {
            $migrated = self::encrypt( $decrypted );
            if ( $migrated !== $decrypted && 0 === strpos( $migrated, 'skyemag_enc:v2:' ) ) {
                $raw['password'] = $migrated;
                self::set( self::API_CREDENTIALS, $raw );
            }
        }

        return [
            'username' => (string) ( $raw['username'] ?? '' ),
            'password' => $decrypted,
            'region'   => (string) ( $raw['region']   ?? 'ro' ),
            'api_code' => (string) ( $raw['api_code']  ?? '' ),
        ];
    }

    // ── Encryption helpers ────────────────────────────────────────────────

    /**
     * Derive a fixed 32-byte AES-256 key from AUTH_KEY.
     * Using the raw hash bytes (third arg `true`) avoids the silent
     * trunc/pad behaviour OpenSSL applies to non-32-byte keys.
     */
    private static function derive_key(): string {
        $secret = defined( 'AUTH_KEY' ) && AUTH_KEY ? AUTH_KEY : 'skyemag_fallback_key';
        return substr( hash( 'sha256', (string) $secret, true ), 0, 32 );
    }

    /**
     * Encrypt a string using a freshly random 16-byte IV per call. Output
     * format: `skyemag_enc:v2:` + base64( IV . CIPHERTEXT ). Returns the
     * input unchanged if openssl is unavailable or entropy is too weak —
     * we refuse to ship a broken cipher rather than silently downgrade.
     */
    public static function encrypt( string $value ): string {
        if ( ! $value || ! function_exists( 'openssl_encrypt' ) ) {
            return $value;
        }

        $strong = false;
        $iv     = openssl_random_pseudo_bytes( 16, $strong );
        if ( ! $strong || 16 !== strlen( (string) $iv ) ) {
            return $value;
        }

        $ct = openssl_encrypt( $value, 'AES-256-CBC', self::derive_key(), OPENSSL_RAW_DATA, $iv );
        if ( false === $ct ) {
            return $value;
        }

        return 'skyemag_enc:v2:' . base64_encode( $iv . $ct );
    }

    /**
     * Decrypt a value previously encrypted with self::encrypt(). Reads
     * both v2 (random-IV) and legacy v1 (static-IV) ciphertexts so
     * upgrades from <=3.4.x don't lose stored credentials. On AUTH_KEY
     * rotation or corruption, clears the stored credentials and surfaces
     * an admin notice so the seller re-enters them via Settings.
     */
    public static function decrypt( string $value ): string {
        if ( ! $value || ! function_exists( 'openssl_decrypt' ) ) {
            return $value;
        }
        if ( 0 !== strpos( $value, 'skyemag_enc:' ) ) {
            return $value; // plain text from a pre-encryption release
        }

        // v2 schema: random IV prepended to ciphertext.
        if ( 0 === strpos( $value, 'skyemag_enc:v2:' ) ) {
            $blob = base64_decode( substr( $value, 15 ), true );
            if ( false === $blob || strlen( $blob ) <= 16 ) {
                return self::decrypt_failure();
            }
            $iv  = substr( $blob, 0, 16 );
            $ct  = substr( $blob, 16 );
            $dec = openssl_decrypt( $ct, 'AES-256-CBC', self::derive_key(), OPENSSL_RAW_DATA, $iv );
            return false !== $dec ? $dec : self::decrypt_failure();
        }

        // Legacy v1: static IV derived from the raw AUTH_KEY string.
        $legacy_key = defined( 'AUTH_KEY' ) && AUTH_KEY ? AUTH_KEY : 'skyemag_fallback_key';
        $legacy_iv  = substr( hash( 'sha256', (string) $legacy_key ), 0, 16 );
        $dec = openssl_decrypt( substr( $value, 12 ), 'AES-256-CBC', $legacy_key, 0, $legacy_iv );
        return false !== $dec ? $dec : self::decrypt_failure();
    }

    /**
     * Internal: returns true if $value is a v1 (static-IV) ciphertext
     * that should be migrated to v2 on next read.
     */
    private static function is_legacy_v1_ciphertext( string $value ): bool {
        return 0 === strpos( $value, 'skyemag_enc:' )
            && 0 !== strpos( $value, 'skyemag_enc:v2:' );
    }

    /**
     * Render the recovery banner when credentials cannot be decrypted.
     * Hooked from Plugin::register_services() via admin_notices.
     */
    public static function render_decrypt_failure_notice(): void {
        if ( ! get_transient( 'skyemag_credentials_decrypt_failure' ) ) {
            return;
        }
        if ( ! current_user_can( 'manage_woocommerce' ) ) {
            return;
        }
        $settings_url = admin_url( 'admin.php?page=skyservers-product-sync-for-emag' );
        printf(
            '<div class="notice notice-error"><p><strong>%s</strong> %s <a href="%s">%s</a></p></div>',
            esc_html__( 'SkyServers eMAG sync:', 'skyservers-product-sync-for-emag' ),
            esc_html__( 'stored API credentials could not be decrypted (most likely cause: WordPress AUTH_KEY changed). The password has been cleared — please re-enter it on the Settings page.', 'skyservers-product-sync-for-emag' ),
            esc_url( $settings_url ),
            esc_html__( 'Open Settings', 'skyservers-product-sync-for-emag' )
        );
        // One-shot: clear the transient so the notice doesn't persist past
        // the seller re-saving credentials.
        delete_transient( 'skyemag_credentials_decrypt_failure' );
    }

    /**
     * Internal: handle AUTH_KEY rotation / ciphertext corruption. Clears
     * the stored credentials and queues an admin notice consumed on next
     * admin page load. Returns empty string so callers fall through to
     * the credentials-required path.
     */
    private static function decrypt_failure(): string {
        // Mark the failure in a transient so admin_notices can render a
        // clear "re-enter your credentials" panel. We deliberately do
        // NOT touch the Logs table here — Logger::log() would fail or
        // generate noise on installs where the table has not been
        // created yet (early activation, MU-plugin auto-load).
        set_transient( 'skyemag_credentials_decrypt_failure', 1, DAY_IN_SECONDS );

        // Clear the corrupted credentials so the next API call short-
        // circuits with an explicit "credentials required" rather than
        // attempting Basic-Auth with a useless ciphertext blob.
        $stored = get_option( self::API_CREDENTIALS, [] );
        if ( is_array( $stored ) && ! empty( $stored['password'] ) ) {
            $stored['password'] = '';
            update_option( self::API_CREDENTIALS, $stored, false );
        }

        return '';
    }

    /**
     * Get cached VAT map: [ '0.11' => 3, '0.21' => 1, ... ]
     */
    public static function vat_cache(): array {
        return (array) self::get( self::VAT_CACHE, [] );
    }

    /**
     * Look up vat_id for a given rate (e.g. 0.11 or 0.21).
     * Returns null if not cached yet.
     */
    public static function vat_id_for_rate( float $rate ): ?int {
        $cache = self::vat_cache();
        $key   = (string) $rate;
        return isset( $cache[ $key ] ) ? (int) $cache[ $key ] : null;
    }

    /**
     * Reverse lookup: given a vat_id integer, return the rate as a float.
     * e.g. vat_id=1 → 0.21, vat_id=3 → 0.11
     * Returns null if not in cache.
     */
    public static function vat_rate_for_id( int $vat_id ): ?float {
        foreach ( self::vat_cache() as $rate_str => $id ) {
            if ( (int) $id === $vat_id ) {
                return (float) $rate_str;
            }
        }
        return null;
    }

    public static function is_bootstrap_done(): bool {
        return (bool) self::get( self::BOOTSTRAP_DONE, false );
    }

    /**
     * Bootstrap pricing strategy. See BOOTSTRAP_MODES for valid values.
     * Returns the default ('global_margin') if the stored value is missing
     * or unknown — keeps callers from having to whitelist defensively.
     */
    public static function bootstrap_mode(): string {
        $val = (string) self::get( self::BOOTSTRAP_MODE, self::BOOTSTRAP_MODE_DEFAULT );
        return in_array( $val, self::BOOTSTRAP_MODES, true ) ? $val : self::BOOTSTRAP_MODE_DEFAULT;
    }

    public static function publish_warranty(): int {
        return max( 0, (int) self::get( self::PUBLISH_WARRANTY, 12 ) );
    }

    public static function publish_resupply_days(): int {
        return max( 0, (int) self::get( self::PUBLISH_RESUPPLY_DAYS, 14 ) );
    }

    public static function is_publish_live(): bool {
        return (bool) self::get( self::PUBLISH_LIVE, false );
    }

    /**
     * Fixed offset added to the WC post ID when forming the eMAG internal
     * offer id for newly published products. Keeps publish ids clear of
     * offers created before this plugin (other feeds/integrations).
     * Default 0 — identical behaviour to previous versions.
     */
    public static function publish_id_offset(): int {
        return max( 0, (int) self::get( self::PUBLISH_ID_OFFSET, 0 ) );
    }

    /**
     * Get ordered list of meta keys to try for Brand.
     * Default: ['pa_brand'] (WC taxonomy attribute).
     *
     * @return string[]
     */
    public static function brand_sources(): array {
        $val = self::get( self::BRAND_SOURCES, null );
        if ( is_array( $val ) && ! empty( $val ) ) {
            return array_values( array_filter( array_map( 'trim', $val ) ) );
        }
        return [ 'pa_brand' ];
    }

    /**
     * Get ordered list of meta keys to try for EAN.
     * Default: ['_global_unique_id'] (WC 9.2+ native GTIN field).
     *
     * @return string[]
     */
    public static function ean_sources(): array {
        $val = self::get( self::EAN_SOURCES, null );
        if ( is_array( $val ) && ! empty( $val ) ) {
            return array_values( array_filter( array_map( 'trim', $val ) ) );
        }
        return [ '_global_unique_id' ];
    }

	public static function setup_step(): int {
		$value = (int) self::get( self::SETUP_STEP, 1 );
		// Clamp to a valid range. Pro caps at 6, Free at 5; we use the higher
		// bound here and let the wizard route enforce per-tier limits.
		if ( $value < 1 ) {
			return 1;
		}
		if ( $value > self::SETUP_STEP_COUNT_PRO ) {
			return self::SETUP_STEP_COUNT_PRO;
		}
		return $value;
	}

	public static function setup_completed(): bool {
		return (bool) self::get( self::SETUP_COMPLETED, false );
	}

	public static function setup_skipped(): bool {
		return (bool) self::get( self::SETUP_SKIPPED, false );
	}

    // ── Bulk save (settings form) ────────────────────────────────────────

    public static function save_settings( array $raw ): array {
        $errors = [];

        self::set( self::MASTER_ENABLED, ! empty( $raw['master_enabled'] ) );

        // Global margin
        $margin = isset( $raw['global_margin'] ) ? (float) $raw['global_margin'] : 0.0;
        if ( $margin < -50 || $margin > 300 ) {
            $errors[] = __( 'Global margin must be between -50% and 300%.', 'skyservers-product-sync-for-emag' );
        } else {
            self::set( self::GLOBAL_MARGIN, $margin );
            // If global min margin not explicitly set, mirror global margin
            if ( ! isset( $raw['global_min_margin'] ) || $raw['global_min_margin'] === '' ) {
                self::set( self::GLOBAL_MIN_MARGIN, $margin );
            }
        }

        // Global min margin
        if ( isset( $raw['global_min_margin'] ) && $raw['global_min_margin'] !== '' ) {
            $min_margin = (float) $raw['global_min_margin'];
            if ( $min_margin < -50 || $min_margin > 300 ) {
                $errors[] = __( 'Global min margin must be between -50% and 300%.', 'skyservers-product-sync-for-emag' );
            } else {
                self::set( self::GLOBAL_MIN_MARGIN, $min_margin );
            }
        }

        // Global max margin
        if ( isset( $raw['global_max_margin'] ) && $raw['global_max_margin'] !== '' ) {
            $max_margin = (float) $raw['global_max_margin'];
            if ( $max_margin < -50 || $max_margin > 1000 ) {
                $errors[] = __( 'Global max margin must be between -50% and 1000%.', 'skyservers-product-sync-for-emag' );
            } else {
                self::set( self::GLOBAL_MAX_MARGIN, $max_margin );
            }
        }

        // Global stock %
        $stock_pct = isset( $raw['global_stock_percent'] ) ? (float) $raw['global_stock_percent'] : 50.0;
        if ( $stock_pct < 0 || $stock_pct > 100 ) {
            $errors[] = __( 'Global stock % must be between 0 and 100.', 'skyservers-product-sync-for-emag' );
        } else {
            self::set( self::GLOBAL_STOCK_PCT, $stock_pct );
        }

        // Minimum WooCommerce stock threshold
        $min_stock = isset( $raw['min_wc_stock'] ) && $raw['min_wc_stock'] !== '' ? (int) $raw['min_wc_stock'] : 0;
        if ( $min_stock < 0 || $min_stock > 9999 ) {
            $errors[] = __( 'Minimum WC stock must be between 0 and 9999.', 'skyservers-product-sync-for-emag' );
        } else {
            self::set( self::MIN_WC_STOCK, $min_stock );
        }

        // Rounding
        $rounding = sanitize_text_field( $raw['rounding_rule'] ?? '2decimals' );
        if ( in_array( $rounding, self::ROUNDING_RULES, true ) ) {
            self::set( self::ROUNDING_RULE, $rounding );
        }

        // Schedule.
        $schedule = sanitize_text_field( $raw['sync_schedule'] ?? self::DEFAULT_SCHEDULE );
        if ( ! in_array( $schedule, self::SCHEDULES, true ) ) {
            $schedule = self::DEFAULT_SCHEDULE;
        }
        $old = (string) self::get( self::SYNC_SCHEDULE, self::DEFAULT_SCHEDULE );
        self::set( self::SYNC_SCHEDULE, $schedule );
        if ( $old !== $schedule ) {
            Sync\Scheduler::reschedule();
        }

        // API credentials
        self::set( self::API_CREDENTIALS, [
            'username' => sanitize_text_field( $raw['api_username'] ?? '' ),
            'password' => self::encrypt( sanitize_text_field( $raw['api_password'] ?? '' ) ),
            'region'   => sanitize_text_field( $raw['api_region']   ?? 'ro' ),
            'api_code' => sanitize_text_field( $raw['api_code']      ?? '' ),
        ] );

        // Publish settings
        $warranty = isset( $raw['publish_warranty'] ) ? (int) $raw['publish_warranty'] : 12;
        if ( $warranty < 0 || $warranty > 120 ) {
            $errors[] = __( 'Warranty must be between 0 and 120 months.', 'skyservers-product-sync-for-emag' );
        } else {
            self::set( self::PUBLISH_WARRANTY, $warranty );
        }

        $resupply = isset( $raw['publish_resupply_days'] ) ? (int) $raw['publish_resupply_days'] : 14;
        if ( $resupply < 0 || $resupply > 365 ) {
            $errors[] = __( 'Resupply days must be between 0 and 365.', 'skyservers-product-sync-for-emag' );
        } else {
            self::set( self::PUBLISH_RESUPPLY_DAYS, $resupply );
        }

        $id_offset = isset( $raw['publish_id_offset'] ) ? (int) $raw['publish_id_offset'] : 0;
        if ( $id_offset < 0 || $id_offset > 900000000 ) {
            $errors[] = __( 'Publish ID offset must be between 0 and 900,000,000.', 'skyservers-product-sync-for-emag' );
        } else {
            self::set( self::PUBLISH_ID_OFFSET, $id_offset );
        }

        self::set( self::PUBLISH_LIVE, ! empty( $raw['publish_live'] ) );

        // Auto-sanitize characteristic values. The consuming code path
        // (CharacteristicMapper) only ships in the Premium build, so saving
        // the option on a Free install has no observable effect; we still
        // persist it unconditionally so the value survives a Free → Premium
        // upgrade without forcing the user to revisit the wizard.
        if ( isset( $raw['auto_sanitize_chars'] ) ) {
            self::set( self::AUTO_SANITIZE_CHARS, ! empty( $raw['auto_sanitize_chars'] ) );
        }

        // Field sources (Brand / EAN meta keys)
        $brand_raw = isset( $raw['brand_sources'] ) && is_array( $raw['brand_sources'] )
            ? array_slice( array_map( 'sanitize_text_field', $raw['brand_sources'] ), 0, 3 )
            : [];
        $brand_clean = array_values( array_filter( array_map( 'trim', $brand_raw ) ) );
        self::set( self::BRAND_SOURCES, ! empty( $brand_clean ) ? $brand_clean : [ 'pa_brand' ] );

        $ean_raw = isset( $raw['ean_sources'] ) && is_array( $raw['ean_sources'] )
            ? array_slice( array_map( 'sanitize_text_field', $raw['ean_sources'] ), 0, 3 )
            : [];
        $ean_clean = array_values( array_filter( array_map( 'trim', $ean_raw ) ) );
        self::set( self::EAN_SOURCES, ! empty( $ean_clean ) ? $ean_clean : [ '_global_unique_id' ] );

        // Log retention.
        if ( isset( $raw['log_retention_days'] ) && '' !== $raw['log_retention_days'] ) {
            $retention = (int) $raw['log_retention_days'];
            if ( $retention < self::LOG_RETENTION_MIN || $retention > self::LOG_RETENTION_MAX ) {
                $errors[] = sprintf(
                    /* translators: %1$d: minimum days, %2$d: maximum days */
                    __( 'Log retention must be between %1$d and %2$d days.', 'skyservers-product-sync-for-emag' ),
                    self::LOG_RETENTION_MIN,
                    self::LOG_RETENTION_MAX
                );
            } else {
                self::set( self::LOG_RETENTION_DAYS, $retention );
            }
        }

        // Sync exclusion filters (free).
        self::set( self::SYNC_MIN_PRICE_EX_VAT, isset( $raw['sync_min_price_ex_vat'] ) ? max( 0.0, (float) $raw['sync_min_price_ex_vat'] ) : 0.0 );
        self::set( self::SYNC_MAX_WEIGHT_KG,    isset( $raw['sync_max_weight_kg']    ) ? max( 0.0, (float) $raw['sync_max_weight_kg']    ) : 0.0 );
        $sku_pattern = isset( $raw['sync_skip_sku_pattern'] )
            ? trim( sanitize_text_field( $raw['sync_skip_sku_pattern'] ) )
            : '';
        if ( '' !== $sku_pattern && false === @preg_match( '/' . $sku_pattern . '/', '' ) ) {
            $errors[] = __( 'Invalid regex in "Skip SKU pattern".', 'skyservers-product-sync-for-emag' );
            $sku_pattern = '';
        }
        self::set( self::SYNC_SKIP_SKU_PATTERN, $sku_pattern );
        self::set( self::SYNC_SKIP_ZERO_STOCK, ! empty( $raw['sync_skip_zero_stock'] ) );

        // Regular-vs-sale price toggle (free).
        self::set( self::USE_REGULAR_PRICE, ! empty( $raw['use_regular_price'] ) );

        // Minimum image pixels for publish readiness. The PublishReadiness
        // consumer ships only in the Premium build, but persist the value
        // unconditionally so a Free → Premium upgrade keeps the setting.
        if ( isset( $raw['min_image_pixels'] ) ) {
            $px = (int) $raw['min_image_pixels'];
            if ( $px < 0 || $px > 5000 ) {
                $errors[] = __( 'Minimum image pixels must be between 0 and 5000.', 'skyservers-product-sync-for-emag' );
            } else {
                self::set( self::MIN_IMAGE_PIXELS, $px );
            }
        }

        // Delete on uninstall flag.
        self::set( self::DELETE_ON_UNINSTALL, ! empty( $raw['delete_on_uninstall'] ) ? 'yes' : '' );

        return $errors;
    }
}
