<?php

/**
 * Plugin Name:       PrintPrice: STL & 3MF Price Calculator for WooCommerce
 * Plugin URI:        https://printprice.one
 * Description:       Let customers upload STL files, see a calculated 3D-print price, and add the quote to their cart. SaaS-backed: pricing happens on PrintPrice's servers (account required).
 * Version:           1.3.9
 * Requires at least: 6.0
 * Requires PHP:      7.4
 * Requires Plugins:  woocommerce
 * WC requires at least: 8.0
 * WC tested up to:   9.4
 * Author:            PrintPrice
 * Author URI:        https://velorion.tech/en
 * License:           GPL v2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       printprice-3d-calculator
 *
 * @package PrintPrice
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
    // No direct access.
}
// ---------------------------------------------------------------------------
// Hard dependency on WooCommerce.
// ---------------------------------------------------------------------------
// The `Requires Plugins: woocommerce` header above gates activation on
// WP 6.5+ — WordPress refuses to enable us if WC isn't installed and
// active. The check below is the safety net for two cases the header
// doesn't cover:
//   1. WordPress versions older than 6.5 (the header is silently
//      ignored there).
//   2. A merchant deactivates WooCommerce while PrintPrice is still
//      active. Without this check, every call into our code that
//      touches WC functions would fatal the admin.
//
// On miss we self-deactivate, hide the misleading "Plugin activated"
// notice, and show a friendly admin notice pointing at the install URL.
add_action( 'admin_init', 'print_price_check_woocommerce_dependency' );
function print_price_check_woocommerce_dependency() {
    if ( class_exists( 'WooCommerce' ) ) {
        return;
    }
    if ( !function_exists( 'is_plugin_active' ) ) {
        require_once ABSPATH . 'wp-admin/includes/plugin.php';
    }
    deactivate_plugins( plugin_basename( __FILE__ ) );
    // Don't lie with the standard "Plugin activated" green banner when
    // we're about to silently turn ourselves off again. We only check
    // for the presence of WordPress core's own ?activate=true query
    // param (which WP itself appends to the URL after a successful
    // plugin activation) and unset it — the value is never read or
    // trusted as user input, so nonce verification doesn't apply.
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    if ( isset( $_GET['activate'] ) ) {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        unset($_GET['activate']);
    }
    add_action( 'admin_notices', function () {
        $install_url = wp_nonce_url( self_admin_url( 'update.php?action=install-plugin&plugin=woocommerce' ), 'install-plugin_woocommerce' );
        echo '<div class="notice notice-error"><p>' . '<strong>PrintPrice</strong> requires <strong>WooCommerce</strong> to be installed and activated. ' . '<a href="' . esc_url( $install_url ) . '">' . esc_html__( 'Install WooCommerce', 'printprice-3d-calculator' ) . '</a> ' . esc_html__( 'first, then re-activate PrintPrice.', 'printprice-3d-calculator' ) . '</p></div>';
    } );
}

// ---------------------------------------------------------------------------
// WooCommerce feature-compatibility declarations.
// ---------------------------------------------------------------------------
// WooCommerce 8.0+ flags plugins that haven't opted in to its newer
// architectures (High-Performance Order Storage, block-based cart /
// checkout). We're compatible with both — see notes below for why —
// so we declare it explicitly and the "incompatible plugin" admin
// notice goes away.
//
// HPOS / custom_order_tables — orders live in their own table rather
//   than wp_posts. We don't store anything in wp_postmeta against an
//   order; the metabox uses wc_get_order()'s WC_Order object and the
//   `woocommerce_page_wc-orders` screen, both HPOS-safe.
//
// cart_checkout_blocks — the storefront widget posts to the WC Store
//   API (/wp-json/wc/store/v1/cart/add-item), which is the
//   block-compatible cart API. The widget does not write to the
//   legacy WC()->cart class directly.
add_action( 'before_woocommerce_init', function () {
    if ( class_exists( '\\Automattic\\WooCommerce\\Utilities\\FeaturesUtil' ) ) {
        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
    }
} );
// ---------------------------------------------------------------------------
// Composer autoloader (includes the Freemius WordPress SDK).
// ---------------------------------------------------------------------------
// The SDK is installed via `composer require freemius/wordpress-sdk` and
// lives at vendor/freemius/wordpress-sdk/. WordPress.org's review team
// now requires the vendor/ folder to be committed to the plugin zip
// (not regenerated at install time), so end users do NOT need Composer.
if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) {
    require_once __DIR__ . '/vendor/autoload.php';
}
// ---------------------------------------------------------------------------
// Freemius — licensing, billing, plugin updates for paid customers.
// ---------------------------------------------------------------------------
// `print_price_fs()` is a global accessor that boots the Freemius SDK lazily. The
// IDs (`id`, `public_key`) are public and tied to the PrintPrice plugin
// record on Freemius (https://dashboard.freemius.com). The Secret Key
// lives only on the apps/woo-backend server (.env), never in the
// plugin — Freemius doesn't need it on the WP side.
//
// After a merchant activates a license via Freemius's hosted modal, we
// mirror the activated key into the existing `print_price_license_key`
// option so all the downstream code (widget, order block, REST calls)
// keeps using the same auth header without changes.
if ( !function_exists( 'print_price_fs' ) ) {
    function print_price_fs() {
        global $print_price_fs;
        if ( !isset( $print_price_fs ) ) {
            $print_price_fs = fs_dynamic_init( array(
                'id'               => '30474',
                'slug'             => 'printprice-wp',
                'type'             => 'plugin',
                'public_key'       => 'pk_a6c3abc09aeba4368c7a05ce7a1a7',
                'anonymous_mode'   => true,
                'is_premium'       => false,
                'has_addons'       => false,
                'has_paid_plans'   => true,
                'is_org_compliant' => true,
                'menu'             => array(
                    'slug'       => 'print-price',
                    'first-path' => 'admin.php?page=print-price-settings',
                    'contact'    => false,
                    'support'    => false,
                ),
                'is_live'          => true,
            ) );
        }
        return $print_price_fs;
    }

    // Init Freemius now.
    print_price_fs();
    // Other plugins can hook 'print_price_fs_loaded' if they want to interact.
    do_action( 'print_price_fs_loaded' );
    /**
     * Identity, and why it is no longer the same thing as entitlement.
     *
     *   IDENTITY   a stable random UUID ("pp-...") minted on first
     *              activation, traded once at /register for an admin
     *              secret ("ppk_..."). That secret is what authenticates
     *              this install for the rest of its life. It never
     *              changes, because everything the shop owns hangs off
     *              the merchant id behind it: materials, pricing,
     *              appearance, quotes, orders.
     *
     *   ENTITLEMENT which Freemius plan the merchant is on, reported to
     *              POST /api/woo/license/link and stored against that
     *              same merchant id.
     *
     * These used to be one value, and on purchase this handler overwrote
     * the admin secret with the Freemius license key. The backend cannot
     * authenticate that key, so the request failed, the self-heal below
     * read the unrecognised value as legacy, wiped it and minted a fresh
     * UUID. The merchant silently became a brand-new free-tier shop:
     * different merchant id, storefront appearance gone, quote history
     * orphaned, still on free limits, and our own reporting showed nobody
     * paying while their subscription was live. It happened to a real shop
     * on the day it subscribed.
     *
     * So the license key is now REPORTED and never stored as identity.
     *
     * Why the UUID exists at all: freemium plugins should let merchants
     * try the product without filling in a form. Anonymous_mode (set
     * above) suppresses the opt-in modal, and this gives the backend
     * something to key on until/unless they upgrade.
     */
    function print_price_ensure_free_identifier() {
        $existing = (string) get_option( 'print_price_license_key', '' );
        // Only generate if option is empty. Don't clobber a Freemius
        // license that's already been activated (which has no "pp-"
        // prefix — sk_xxx or similar).
        if ( '' === $existing ) {
            // wp_generate_uuid4() is available WP 4.4+; we already require
            // WP 6.0+ in the plugin header, so no need for a fallback path.
            update_option( 'print_price_license_key', 'pp-' . wp_generate_uuid4() );
        }
    }

    /**
     * Tell the backend which Freemius plan this install is on.
     *
     * Reports; never writes identity. See the block above for what the
     * old overwrite-on-purchase behaviour did to a live shop.
     *
     * Safe to call on every license event and once a day: the backend
     * keeps a positive result for five minutes, and a failed lookup
     * leaves the stored tier alone rather than downgrading a paying shop
     * because an API call did not answer.
     */
    function print_price_report_license_to_backend() {
        $credential = (string) get_option( 'print_price_license_key', '' );
        if ( '' === $credential ) {
            return;
            // Nothing to authenticate with yet.
        }
        $key = '';
        $fs = print_price_fs();
        if ( $fs->is_registered() && $fs->is_paying_or_trial() ) {
            $license = $fs->_get_license();
            // Freemius's "secret_key" on the License object IS the
            // activation key: they reuse the field name. It is not the
            // API Secret Key, which is a completely different value and
            // must never leave the server.
            if ( $license && !empty( $license->secret_key ) ) {
                $key = (string) $license->secret_key;
            }
        }
        // An empty key is a real answer, not a no-op: it means this
        // install has no paid license, so a cancellation is recorded
        // instead of leaving a stale paid tier behind forever.
        wp_remote_post( print_price_backend_url_server() . '/api/woo/license/link', array(
            'timeout' => 10,
            'headers' => array(
                'Content-Type'         => 'application/json',
                'X-Printprice-License' => $credential,
            ),
            'body'    => wp_json_encode( array(
                'key'           => $key,
                'pluginVersion' => PRINT_PRICE_VERSION,
            ) ),
        ) );
    }

    /**
     * Keep the local identity sane, and report the plan.
     *
     * The only thing this may do to print_price_license_key is fill it in
     * when it is empty. It must never replace a working credential: that
     * is what created a second shop for a merchant who had done nothing
     * wrong but subscribe.
     */
    function print_price_sync_license_from_freemius() {
        print_price_ensure_free_identifier();
        print_price_report_license_to_backend();
    }

    print_price_fs()->add_action( 'after_license_change', 'print_price_sync_license_from_freemius' );
    print_price_fs()->add_action( 'after_premium_version_activation', 'print_price_sync_license_from_freemius' );
    print_price_fs()->add_action( 'after_account_connection', 'print_price_sync_license_from_freemius' );
    print_price_fs()->add_action( 'after_uninstall', function () {
        // Don't wipe — merchant may reactivate. Same policy as
        // register_deactivation_hook below.
    } );
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
define( 'PRINT_PRICE_VERSION', '1.3.9' );
define( 'PRINT_PRICE_FILE', __FILE__ );
define( 'PRINT_PRICE_DIR', plugin_dir_path( __FILE__ ) );
define( 'PRINT_PRICE_URL', plugin_dir_url( __FILE__ ) );
/**
 * Get the configured PrintPrice backend base URL. Defaults to production
 * (wp.printprice.one); developers can override per-site by defining
 * PRINT_PRICE_BACKEND_URL in wp-config.php (e.g. http://localhost:4000
 * when running against the local apps/woo-backend).
 */
function print_price_backend_url() {
    $raw = ( defined( 'PRINT_PRICE_BACKEND_URL' ) ? PRINT_PRICE_BACKEND_URL : get_option( 'print_price_backend_url' ) );
    if ( !$raw ) {
        return 'https://wp.printprice.one';
    }
    // trim whitespace first — pasted URLs commonly carry a stray space.
    // Then rtrim trailing slashes so consumers can append paths.
    return rtrim( trim( (string) $raw ), '/' );
}

/**
 * Backend URL for SERVER-SIDE requests (wp_remote_*). In Docker-based
 * local dev the WP container can't reach `http://localhost:4000` (which
 * means "the container itself"); it has to use `host.docker.internal:4000`.
 * Define PRINT_PRICE_BACKEND_URL_INTERNAL in wp-config (or .wp-env.json)
 * to override server-side calls while keeping the browser-facing URL
 * (iframe src, widget script tag) using the regular value.
 *
 * In production both resolve to the same URL.
 */
function print_price_backend_url_server() {
    if ( defined( 'PRINT_PRICE_BACKEND_URL_INTERNAL' ) ) {
        return rtrim( trim( (string) PRINT_PRICE_BACKEND_URL_INTERNAL ), '/' );
    }
    return print_price_backend_url();
}

// ---------------------------------------------------------------------------
// Activation / deactivation
// ---------------------------------------------------------------------------
register_activation_hook( __FILE__, 'print_price_on_activate' );
function print_price_on_activate() {
    // Seed defaults the first time the plugin is activated.
    if ( false === get_option( 'print_price_license_key' ) ) {
        add_option( 'print_price_license_key', '' );
    }
    if ( false === get_option( 'print_price_wc_consumer_key' ) ) {
        add_option( 'print_price_wc_consumer_key', '' );
    }
    if ( false === get_option( 'print_price_wc_consumer_secret' ) ) {
        add_option( 'print_price_wc_consumer_secret', '' );
    }
    // Two-track identity: give the merchant a free-tier UUID the
    // moment they activate, so the plugin works without an opt-in.
    // Defined in the Freemius bootstrap block at the top of this file.
    if ( function_exists( 'print_price_ensure_free_identifier' ) ) {
        print_price_ensure_free_identifier();
    }
    print_price_schedule_heartbeat();
}

// Self-heal: keep print_price_license_key on a recognised shape every
// admin pageload. Cheap: one DB read, since the option is autoloaded.
//   - Empty                -> generate fresh pp-<uuid>
//   - Already pp-<uuid>    -> leave alone
//   - Already ppk_...      -> leave alone, it is this install's credential
//   - Anything else        -> legacy/stale value (instawp-test, manual
//                             edits, fs-site-NNN from before the UUID
//                             switch). Replaced ONLY when this install has
//                             never registered.
//
// Minting a new identifier is the destructive step, so it is now gated on
// having no public key. Once an install has registered, its merchant id owns
// the shop's materials, pricing, appearance and quote history, and a fresh
// UUID silently starts an empty shop in its place. That is exactly what
// happened to a merchant the day they subscribed: the purchase handler
// replaced their credential with a Freemius key, and this branch then read
// that key as legacy and regenerated. Two records, one shop, and the
// appearance they had set was on the record they were no longer using.
add_action( 'admin_init', function () {
    if ( !function_exists( 'print_price_ensure_free_identifier' ) || !function_exists( 'pp_fs' ) ) {
        return;
    }
    // Dev-tools sites need a stable manually-set value (ops/support
    // debugging a merchant's data, screenshots taken under a specific
    // identifier). Skip the heal so values like `instawp-test` survive
    // across pageloads.
    if ( defined( 'PRINTPRICE_DEV_TOOLS' ) && PRINTPRICE_DEV_TOOLS ) {
        return;
    }
    $current = (string) get_option( 'print_price_license_key', '' );
    if ( '' === $current ) {
        print_price_ensure_free_identifier();
        return;
    }
    if ( 0 === strpos( $current, 'pp-' ) ) {
        return;
        // Pre-split identifier, still valid until this install registers.
    }
    // A backend-issued admin secret. MUST be left alone: the "legacy value"
    // branch below would wipe it and mint a fresh pp-<uuid>, which the backend
    // now refuses for a registered install — locking the merchant out of their
    // own dashboard on the next admin pageload.
    if ( 0 === strpos( $current, 'ppk_' ) ) {
        return;
    }
    // A registered install must keep whatever it has, even if we no longer
    // recognise the shape. A credential we cannot parse is a support question;
    // a new identifier is a lost shop. print_price_credential_notice() below is
    // what makes that visible, because silently keeping an unusable credential
    // leaves the merchant staring at a dashboard that will not load.
    if ( print_price_has_public_key() ) {
        return;
    }
} );
// Say something when this install holds a credential we cannot use.
//
// Only reachable in one state: registered (so there is a shop behind it) with a
// value that is neither our issued secret nor a pre-registration UUID. That is
// what an install looked like after the old purchase handler replaced its
// credential with a Freemius licence key. The self-heal used to "fix" it by
// minting a new identity, which quietly started an empty shop and left the
// merchant's own materials, pricing and appearance behind. It keeps the value
// now, so the dashboard will not load until support reissues the key, and the
// merchant is owed an explanation rather than a blank screen.
add_action( 'admin_notices', 'print_price_credential_notice' );
function print_price_credential_notice() {
    if ( !function_exists( 'print_price_has_public_key' ) || !current_user_can( 'manage_options' ) ) {
        return;
    }
    $current = (string) get_option( 'print_price_license_key', '' );
    if ( '' === $current || !print_price_has_public_key() ) {
        return;
    }
    if ( 0 === strpos( $current, 'pp-' ) || 0 === strpos( $current, 'ppk_' ) ) {
        return;
    }
    echo '<div class="notice notice-error"><p><strong>' . esc_html__( 'PrintPrice cannot sign in to your account.', 'printprice-3d-calculator' ) . '</strong> ' . esc_html__( 'Your calculator keeps working on your storefront, and your materials, pricing and orders are safe. Contact support and we will reissue your key.', 'printprice-3d-calculator' ) . '</p></div>';
}

// Report the plan from wp-admin as a fallback, so a renewal, a cancellation or
// a plan change lands even when this host has disabled WordPress cron.
// Freemius's own hooks cover the moment of purchase; this covers everything
// that happens on their servers afterwards, and installs that upgraded while
// this reporting did not exist yet.
//
// Transient, not an option: if it fails to save, the worst case is reporting
// again on the next pageload, which the backend answers from its own cache.
add_action( 'admin_init', function () {
    if ( !function_exists( 'print_price_report_license_to_backend' ) ) {
        return;
    }
    if ( get_transient( 'print_price_license_reported' ) ) {
        return;
    }
    set_transient( 'print_price_license_reported', 1, 6 * HOUR_IN_SECONDS );
    print_price_report_license_to_backend();
    // Reporting NEVER writes identity.
    //
    // This used to blank print_price_license_key here and re-mint, carrying the
    // comment "never registered, so there is no shop behind this value to lose".
    // Nothing checked that. It ran on every install once a day, on the first
    // wp-admin pageload after the transient expired, so a live shop was handed a
    // brand new merchant id daily while print_price_public_key still pointed at
    // the old one. wp-admin then showed an empty shop (no materials, no pricing,
    // default texts) and the storefront went on serving the previous identity:
    // the two halves of one install talking to two different shops. Merchants
    // re-entered their filaments into the new empty shop and lost them again the
    // next day.
    //
    // Ensuring an identifier is all this needs, and it only writes when the option
    // is genuinely empty, so a registered shop keeps the id it already has.
    print_price_ensure_free_identifier();
} );
// A real background heartbeat. WordPress cron runs on ordinary site traffic,
// so it does not depend on a merchant opening wp-admin. Six hours keeps the
// write rate small while giving the backend enough evidence to distinguish an
// active plugin from one that has been removed or deactivated.
add_filter( 'cron_schedules', 'print_price_add_heartbeat_schedule' );
function print_price_add_heartbeat_schedule(  $schedules  ) {
    $schedules['print_price_six_hours'] = array(
        'interval' => 6 * HOUR_IN_SECONDS,
        'display'  => esc_html__( 'Every six hours', 'printprice-3d-calculator' ),
    );
    return $schedules;
}

add_action( 'init', 'print_price_schedule_heartbeat' );
function print_price_schedule_heartbeat() {
    if ( !wp_next_scheduled( 'print_price_heartbeat' ) ) {
        wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, 'print_price_six_hours', 'print_price_heartbeat' );
    }
}

add_action( 'print_price_heartbeat', 'print_price_run_heartbeat' );
function print_price_run_heartbeat() {
    if ( function_exists( 'print_price_report_license_to_backend' ) ) {
        print_price_report_license_to_backend();
        set_transient( 'print_price_license_reported', 1, 6 * HOUR_IN_SECONDS );
    }
}

// ---------------------------------------------------------------------------
// Credential split: one key became two
// ---------------------------------------------------------------------------
//
// Until now `print_price_license_key` did three jobs at once. It identified the
// merchant, it authenticated wp-admin, AND the storefront shortcode printed it
// into public product-page HTML as data-license. Anyone could read a shop's
// admin credential with view-source and then read or change that shop's orders,
// approvals and pricing.
//
// The backend now issues two values in its place:
//
//   print_price_license_key  the admin secret. Stays in wp-admin, never
//                            rendered on the storefront.
//   print_price_public_key   what the shortcode prints. Not a secret — it only
//                            reaches the endpoints a shopper needs.
//
// Registration is a single call carrying the OLD key, so it authenticates
// itself. The backend issues the pair exactly once per install and then refuses
// the old key for that install, which means each store's exposure ends the
// moment its own plugin updates.
/** Have we completed the swap on this install? */
function print_price_has_public_key() {
    return '' !== (string) get_option( 'print_price_public_key', '' );
}

/**
 * Trade the current key for an admin secret + a public storefront key.
 *
 * Safe to call repeatedly: the backend returns the public key and no new
 * secret once an install is registered, and we only overwrite the license
 * option when a secret actually comes back. Returns true when the install
 * ends up registered.
 */
function print_price_register_keys() {
    $license = (string) get_option( 'print_price_license_key', '' );
    if ( '' === $license ) {
        return false;
    }
    $response = wp_remote_post( print_price_backend_url_server() . '/api/woo/register', array(
        'timeout' => 10,
        'headers' => array(
            'Content-Type'         => 'application/json',
            'X-Printprice-License' => $license,
        ),
        'body'    => wp_json_encode( array(
            'siteUrl' => home_url(),
        ) ),
    ) );
    if ( is_wp_error( $response ) ) {
        return false;
    }
    $code = wp_remote_retrieve_response_code( $response );
    if ( $code < 200 || $code >= 300 ) {
        $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( 409 === $code && is_array( $decoded ) && 'existing_install_requires_recovery' === ($decoded['code'] ?? '') ) {
            print_price_rejoin_note( 'needs_support', 'backend protected an existing installation for this site' );
        }
        return false;
    }
    $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
    if ( !is_array( $decoded ) || empty( $decoded['success'] ) || empty( $decoded['data'] ) ) {
        return false;
    }
    $data = $decoded['data'];
    // Order matters, and it is the credential FIRST.
    //
    // Issuing the secret already retired the old key on the backend, so from
    // this moment the only value that can authenticate this install is the one
    // in $data['licenseKey']. If the process dies before we store it, the
    // merchant is locked out of their own dashboard with no way to recover:
    // re-registering needs a working credential, and neither key works.
    //
    // Storing it first makes the same crash harmless — we end up authenticated
    // with no public key, the shortcode keeps using the old fallback, and the
    // next admin pageload re-registers and collects the public key.
    if ( !empty( $data['licenseKey'] ) ) {
        update_option( 'print_price_license_key', (string) $data['licenseKey'] );
    }
    if ( !empty( $data['publicKey'] ) ) {
        update_option( 'print_price_public_key', (string) $data['publicKey'] );
    }
    if ( print_price_has_public_key() ) {
        delete_option( 'print_price_rejoin_state' );
    }
    return print_price_has_public_key();
}

/**
 * Register on any admin pageload where we are not registered yet.
 *
 * admin_init rather than an upgrade hook because it covers every route a
 * merchant can arrive by — auto-update, manual zip, WP-CLI, a fresh install —
 * and a merchant who updates the plugin lands in wp-admin within seconds.
 * Throttled to one attempt per hour so an unreachable backend costs a dead
 * 10-second request once, not on every page.
 */
add_action( 'admin_init', function () {
    if ( print_price_has_public_key() ) {
        return;
    }
    if ( get_transient( 'print_price_register_backoff' ) ) {
        return;
    }
    set_transient( 'print_price_register_backoff', 1, HOUR_IN_SECONDS );
    print_price_register_keys();
}, 20 );
// ---------------------------------------------------------------------------
// Rejoining a shop this install was split away from
// ---------------------------------------------------------------------------
//
// 1.3.2 blanked print_price_license_key once a day and minted a fresh uuid. That
// option is the tenant key, so the install woke up as a new empty shop: no
// materials, default pricing, default texts. The storefront kept working from
// print_price_public_key, which still pointed at the real shop, so the merchant
// saw their own site serving settings that wp-admin claimed did not exist.
//
// The state is recognisable without guessing at anything. Holding a public key
// means this install registered a shop at some point. A `pp-` identity means the
// current identity has NOT registered. Both at once can only be the split.
//
// Proving which shop is ours is the careful part. The public key cannot do it:
// it is printed in our own storefront HTML, so anyone could claim any shop with
// it. What the wipe never touched is the WooCommerce consumer secret, and
// holding that already means owning the store. So we prove knowledge of it with
// an HMAC and never send it. See routes/rejoin.js for the other half.
/** Are we authenticated as a shop that is not the one our storefront serves? */
function print_price_is_split_install() {
    $license = (string) get_option( 'print_price_license_key', '' );
    return print_price_has_public_key() && 0 === strpos( $license, 'pp-' );
}

/** Where the merchant (and support) can read what happened. */
function print_price_rejoin_note(  $state, $detail = ''  ) {
    update_option( 'print_price_rejoin_state', array(
        'state'  => (string) $state,
        'detail' => (string) $detail,
        'at'     => gmdate( 'c' ),
    ) );
    // error_log rather than silence: this runs unattended, and when a merchant
    // asks why their shop came back (or did not) the answer needs to exist
    // somewhere their host can show them. No credentials in the message.
    if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
        error_log( sprintf( '[PrintPrice] rejoin %s%s', $state, ( $detail ? ': ' . $detail : '' ) ) );
    }
}

/**
 * Ask the backend to put this install back on the shop it already owns.
 *
 * @return bool True when this install is now authenticated as its old shop.
 */
function print_price_try_rejoin() {
    $public_key = (string) get_option( 'print_price_public_key', '' );
    $ck = (string) get_option( 'print_price_wc_consumer_key', '' );
    $cs = (string) get_option( 'print_price_wc_consumer_secret', '' );
    $site_url = home_url();
    if ( '' === $public_key ) {
        print_price_rejoin_note( 'skipped', 'no storefront key, so there is no shop to rejoin' );
        return false;
    }
    if ( '' === $ck || '' === $cs ) {
        // The one case we cannot solve automatically: nothing left to prove
        // ownership with. Says so plainly so support has somewhere to start.
        print_price_rejoin_note( 'needs_support', 'no WooCommerce REST credentials stored to prove ownership' );
        return false;
    }
    $proof = hash_hmac( 'sha256', $public_key . ':' . $ck . ':' . $site_url, $cs );
    $response = wp_remote_post( print_price_backend_url_server() . '/api/woo/rejoin', array(
        'timeout' => 10,
        'headers' => array(
            'Content-Type' => 'application/json',
        ),
        'body'    => wp_json_encode( array(
            'publicKey'   => $public_key,
            'consumerKey' => $ck,
            'proof'       => $proof,
            'siteUrl'     => $site_url,
        ) ),
    ) );
    if ( is_wp_error( $response ) ) {
        print_price_rejoin_note( 'unreachable', $response->get_error_message() );
        return false;
    }
    $code = wp_remote_retrieve_response_code( $response );
    if ( $code < 200 || $code >= 300 ) {
        print_price_rejoin_note( 'refused', sprintf( 'backend answered %d', $code ) );
        return false;
    }
    $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
    $data = ( is_array( $decoded ) && !empty( $decoded['data'] ) ? $decoded['data'] : array() );
    if ( empty( $data['licenseKey'] ) ) {
        print_price_rejoin_note( 'refused', 'no credential returned' );
        return false;
    }
    // Identity last, same ordering rule as registration: if this dies halfway the
    // next pageload simply tries again, and nothing has been lost.
    update_option( 'print_price_license_key', (string) $data['licenseKey'] );
    if ( !empty( $data['publicKey'] ) ) {
        update_option( 'print_price_public_key', (string) $data['publicKey'] );
    }
    print_price_rejoin_note( 'rejoined', ( isset( $data['merchantId'] ) ? 'shop ' . substr( (string) $data['merchantId'], 0, 12 ) . '…' : '' ) );
    return true;
}

// Attempt the rejoin on any admin pageload where this install looks split.
// Throttled, because a refusal needs a person and hammering helps nobody.
add_action( 'admin_init', function () {
    if ( !print_price_is_split_install() ) {
        return;
    }
    if ( get_transient( 'print_price_rejoin_backoff' ) ) {
        return;
    }
    set_transient( 'print_price_rejoin_backoff', 1, HOUR_IN_SECONDS );
    print_price_try_rejoin();
}, 19 );
// Tell the merchant, rather than letting a shop silently reappear or not.
add_action( 'admin_notices', function () {
    if ( !current_user_can( 'manage_options' ) ) {
        return;
    }
    $note = get_option( 'print_price_rejoin_state' );
    if ( !is_array( $note ) || empty( $note['state'] ) ) {
        return;
    }
    if ( 'rejoined' === $note['state'] ) {
        echo '<div class="notice notice-success is-dismissible"><p><strong>' . esc_html__( 'PrintPrice reconnected to your shop.', 'printprice-3d-calculator' ) . '</strong> ' . esc_html__( 'An update briefly signed this site in as a new, empty shop. Your materials, pricing and appearance were never deleted and are back.', 'printprice-3d-calculator' ) . '</p></div>';
        delete_option( 'print_price_rejoin_state' );
        return;
    }
    if ( 'needs_support' === $note['state'] || 'refused' === $note['state'] ) {
        echo '<div class="notice notice-error"><p><strong>' . esc_html__( 'PrintPrice could not reconnect to your shop.', 'printprice-3d-calculator' ) . '</strong> ' . esc_html__( 'Your settings are not lost, but this site cannot prove which shop it belongs to. Contact support with your site address and we will reconnect it.', 'printprice-3d-calculator' ) . '</p></div>';
    }
} );
register_deactivation_hook( __FILE__, 'print_price_on_deactivate' );
function print_price_on_deactivate() {
    wp_clear_scheduled_hook( 'print_price_heartbeat' );
    // Settings are intentionally retained so reactivation does not lose the
    // merchant's license or key configuration.
}

// ---------------------------------------------------------------------------
// Admin: top-level menu + settings page
// ---------------------------------------------------------------------------
add_action( 'admin_menu', 'print_price_admin_menu' );
function print_price_admin_menu() {
    // PrintPrice mark (two filament spools) as a monochrome white SVG, passed as
    // a base64 data URI. WordPress recognises this form as a menu SVG and applies
    // the standard admin-menu icon theming (dimmed grey at rest, full white when
    // the item is active or hovered), so a single white fill reads correctly in
    // both states without hardcoding scheme colours.
    $icon_svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200"><path fill="#fff" d="M784.751 958.549C800.084 990.57 819.644 1019.88 843.583 1046.31C878.006 1084.31 920.746 1115.42 971.499 1139.91C942.297 1159.42 911.057 1170 878.571 1170C787.041 1170 705.336 1086.12 651.633 954.741L784.751 958.549ZM878.571 0C1040.31 0 1171.43 261.913 1171.43 585C1171.43 805.113 1110.54 996.805 1020.61 1096.66C963.789 1073.24 918.913 1042.39 884.989 1004.94C869.819 988.195 856.639 969.879 845.439 949.98C857.061 943.244 866.951 933.013 873.367 919.703C927.007 808.407 947.627 681.661 944.727 565.312C941.926 452.976 917.111 347.505 876.186 273.12L872.181 266.016C859.199 243.628 836.231 232.5 813.797 232.5H644.839C698.301 91.2648 783.097 0 878.571 0ZM700.223 292.5C711.109 310.598 724.196 346.041 733.621 405.381C744.149 471.652 749.529 563.832 744.127 685.958L742.899 710.786C739.177 779.172 745.18 841.794 761.621 897.861L641.364 894.418C669.874 802.023 685.714 696.331 685.714 585C685.714 480.404 671.707 380.792 646.373 292.5H700.223ZM813.797 292.5C818.881 292.5 822.034 294.923 823.367 297.217L826.771 303.252C861.601 366.621 884.983 461.488 887.611 566.88C890.314 675.276 870.974 791.812 822.391 892.617C822.007 893.412 821.513 894.168 820.926 894.873C803.329 843.228 796.206 783.157 799.959 714.214L801.214 688.798C806.753 563.622 801.343 467.014 789.983 395.508C783.4 354.069 774.583 319.578 763.951 292.5H813.797Z"/><path fill="#fff" d="M1020.61 1096.66C1069.93 1116.98 1121.49 1131.41 1189.48 1139.91V1199.79C1105.48 1189.29 1032.67 1169.43 971.499 1139.91C988.634 1128.47 1005.05 1113.93 1020.61 1096.66Z"/><path fill="#fff" d="M314.286 0C487.861 0 628.571 261.913 628.571 585C628.571 908.086 487.861 1170 314.286 1170C140.711 1170 0 908.086 0 585C0 261.913 140.711 0 314.286 0ZM307.143 330C284.776 330 265.78 341.108 251.046 356.207C236.443 371.172 224.59 391.377 215.263 413.979C196.569 459.281 185.714 519.768 185.714 585C185.714 650.232 196.569 710.72 215.263 756.021C224.59 778.623 236.443 798.828 251.046 813.793C265.78 828.893 284.776 840 307.143 840C329.51 840 348.506 828.893 363.24 813.793C377.843 798.828 389.696 778.623 399.023 756.021C417.717 710.72 428.571 650.232 428.571 585C428.571 519.768 417.717 459.281 399.023 413.979C389.696 391.377 377.843 371.172 363.24 356.207C348.506 341.108 329.51 330 307.143 330ZM307.143 390C310.414 390 315.841 391.485 323.326 399.156C330.94 406.959 339.107 419.715 346.581 437.827C361.493 473.96 371.429 525.972 371.429 585C371.429 644.028 361.493 696.041 346.581 732.172C339.107 750.285 330.94 763.041 323.326 770.844C315.841 778.515 310.414 780 307.143 780C303.871 780 298.444 778.515 290.96 770.844C283.346 763.041 275.179 750.285 267.704 732.172C252.793 696.041 242.857 644.028 242.857 585C242.857 525.972 252.793 473.96 267.704 437.827C275.179 419.715 283.346 406.959 290.96 399.156C298.444 391.485 303.871 390 307.143 390Z"/></svg>';
    $icon_uri = 'data:image/svg+xml;base64,' . base64_encode( $icon_svg );
    add_menu_page(
        __( 'PrintPrice', 'printprice-3d-calculator' ),
        __( 'PrintPrice', 'printprice-3d-calculator' ),
        'manage_woocommerce',
        'print-price',
        'print_price_render_dashboard',
        $icon_uri,
        56
    );
    add_submenu_page(
        'print-price',
        __( 'Settings — PrintPrice', 'printprice-3d-calculator' ),
        __( 'Settings', 'printprice-3d-calculator' ),
        'manage_woocommerce',
        'print-price-settings',
        'print_price_render_settings'
    );
}

/**
 * Cache-bust value for one of our shipped local assets.
 *
 * Returns `PRINT_PRICE_VERSION.<mtime>` if the file exists, else just
 * PRINT_PRICE_VERSION. Using filemtime() in the ?ver= query means the
 * browser refetches the asset whenever its bytes change — even within a
 * single PRINT_PRICE_VERSION release (handy for in-place hotfixes and
 * during dev/test cycles where the version string isn't bumped between
 * iterations).
 */
function print_price_asset_ver(  $relative_path  ) {
    $full = PRINT_PRICE_DIR . ltrim( $relative_path, '/' );
    if ( file_exists( $full ) ) {
        return PRINT_PRICE_VERSION . '.' . filemtime( $full );
    }
    return PRINT_PRICE_VERSION;
}

/**
 * Per-screen asset registration. WP.org requires inline <style>/<script>
 * blocks to be enqueued through the proper APIs — see assets/admin-dashboard.css
 * for the dashboard iframe layout and assets/admin-settings.js for the
 * Test-connection button. Hook is screen-scoped so the global iframe-layout
 * CSS doesn't leak onto other admin screens.
 */
add_action( 'admin_enqueue_scripts', 'print_price_enqueue_admin_assets' );
function print_price_enqueue_admin_assets(  $hook  ) {
    if ( 'toplevel_page_print-price' === $hook ) {
        wp_enqueue_style(
            'print-price-admin-dashboard',
            PRINT_PRICE_URL . 'assets/admin-dashboard.css',
            array(),
            print_price_asset_ver( 'assets/admin-dashboard.css' )
        );
        // Bridge for the iframe SPA's <SetupBanner> "Open Settings" button.
        // SPA can't directly navigate the parent (cross-origin), so it
        // postMessage()s us; we navigate WP-admin to PrintPrice → Settings.
        wp_enqueue_script(
            'print-price-admin-dashboard-bridge',
            PRINT_PRICE_URL . 'assets/admin-dashboard.js',
            array(),
            print_price_asset_ver( 'assets/admin-dashboard.js' ),
            true
        );
        $backend_host = wp_parse_url( print_price_backend_url(), PHP_URL_HOST );
        $backend_scheme = wp_parse_url( print_price_backend_url(), PHP_URL_SCHEME );
        wp_localize_script( 'print-price-admin-dashboard-bridge', 'PrintPriceDashboard', array(
            'iframeOrigin'        => (( $backend_scheme ? $backend_scheme : 'https' )) . '://' . $backend_host,
            'settingsUrl'         => admin_url( 'admin.php?page=print-price-settings' ),
            'upgradeUrl'          => print_price_fs()->get_upgrade_url( WP_FS__PERIOD_MONTHLY ),
            'checkoutUrlTemplate' => print_price_fs()->checkout_url( WP_FS__PERIOD_MONTHLY, false, array(
                'plan_id' => '__PLAN_ID__',
            ) ),
            'accountUrl'          => print_price_fs()->get_account_url(),
        ) );
    }
    if ( 'print-price_page_print-price-settings' === $hook ) {
        $license = (string) get_option( 'print_price_license_key', '' );
        $backend = print_price_backend_url();
        if ( '' !== $license && '' !== $backend ) {
            wp_enqueue_script(
                'print-price-admin-settings',
                PRINT_PRICE_URL . 'assets/admin-settings.js',
                array(),
                print_price_asset_ver( 'assets/admin-settings.js' ),
                true
            );
            wp_localize_script( 'print-price-admin-settings', 'PrintPriceSettings', array(
                'backend' => $backend,
            ) );
        }
    }
    // Setup-prompt notice — enqueue the dismiss-handler JS on any admin
    // screen where the notice itself will render (see
    // print_price_should_show_setup_notice for the visibility rules).
    if ( print_price_should_show_setup_notice() ) {
        wp_enqueue_script(
            'print-price-admin-notice',
            PRINT_PRICE_URL . 'assets/admin-notice.js',
            array(),
            print_price_asset_ver( 'assets/admin-notice.js' ),
            true
        );
        wp_localize_script( 'print-price-admin-notice', 'PrintPriceNotice', array(
            'ajaxUrl' => admin_url( 'admin-ajax.php' ),
            'nonce'   => wp_create_nonce( 'print_price_dismiss_setup_notice' ),
        ) );
    }
}

/**
 * Decide whether to render the "configure WooCommerce REST keys" admin
 * notice. The rules:
 *   - User must be able to manage WooCommerce (admins, shop managers).
 *   - We don't nag on the PrintPrice screens themselves — they're already
 *     where we'd be telling them to go.
 *   - We don't show if WC consumer key + secret are both saved — the
 *     setup is complete, no need to nag.
 *   - We don't show if the current user has dismissed it. Stored in user
 *     meta (per-admin choice) rather than as a site option so a second
 *     admin still gets the prompt on their first visit.
 *
 * Used by both admin_enqueue_scripts (to decide whether to load the
 * dismiss-handler JS) and admin_notices (to decide whether to print the
 * notice HTML). Centralised so they can't drift.
 */
/**
 * Are WooCommerce REST credentials saved? One definition, used by the setup
 * notice, the not-connected warning, the storefront owner warning and the
 * dashboard iframe's wc_configured flag, so they cannot disagree about what
 * "connected" means.
 */
function print_price_wc_is_connected() {
    $ck = (string) get_option( 'print_price_wc_consumer_key', '' );
    $cs = (string) get_option( 'print_price_wc_consumer_secret', '' );
    return '' !== $ck && '' !== $cs;
}

function print_price_should_show_setup_notice() {
    if ( !is_admin() || !current_user_can( 'manage_woocommerce' ) ) {
        return false;
    }
    // Suppress on PrintPrice's own admin pages:
    //   - print-price (Dashboard) — that page is dominated by a full-page
    //     iframe loading wp.printprice.one/admin/. A WP admin notice
    //     rendered above it interfered with the iframe's React app
    //     rendering (cards stopped appearing; reproduced on Firefox
    //     1.0.4 vs 1.0.3-from-WP.org). The in-app welcome experience for
    //     that screen will live in the React SPA itself (Hetzner-side,
    //     separate release), so the merchant sees onboarding in the
    //     context where they need to act on it.
    //   - print-price-settings — that's where the notice's CTA sends
    //     them; pointless nag on the destination page.
    //
    // We check the ?page= query param instead of get_current_screen()->id
    // because WP composes submenu screen IDs from
    // sanitize_title($parent_menu_title), which strips the hyphen in
    // "PrintPrice" → "printprice" — so the screen ID is
    // `printprice_page_print-price-settings`, not the slug-derived form.
    // The ?page= value is exactly the menu slug, on every site.
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $page = ( isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '' );
    if ( in_array( $page, array('print-price', 'print-price-settings'), true ) ) {
        return false;
    }
    if ( print_price_wc_is_connected() ) {
        return false;
    }
    if ( get_user_meta( get_current_user_id(), 'print_price_setup_notice_dismissed', true ) ) {
        return false;
    }
    return true;
}

/**
 * Render the setup-prompt notice. The HTML uses WP's standard
 * `notice notice-info is-dismissible` so it picks up core's X-button
 * behaviour for free; assets/admin-notice.js wires the click to
 * admin-ajax so the dismissal survives reloads.
 */
/**
 * Escalated warning: the calculator is LIVE on the storefront but WooCommerce is
 * not connected, so every Add-to-Cart is failing for real customers.
 *
 * Deliberately different from the welcome notice above:
 *   - `notice-error`, not `notice-info` — this is lost sales, not a tip.
 *   - NOT dismissible. The welcome notice is dismissible, and that is exactly
 *     how stores ended up live-but-unconnected for weeks: the merchant waved the
 *     tip away and nothing ever mentioned it again. This one goes away when the
 *     store is connected, which is the only thing that actually fixes it.
 *   - Shown on PrintPrice's own pages too, EXCEPT the settings page it links to.
 *     (The dashboard page hosts a full-page iframe; a notice above it is
 *     tolerable here because the alternative is silent failure.)
 *
 * "Live" means the shortcode/block rendered within the last 30 days — see
 * print_price_widget_shortcode. An install that was never published only gets
 * the gentle welcome notice.
 */
add_action( 'admin_notices', 'print_price_render_not_connected_warning' );
function print_price_render_not_connected_warning() {
    if ( !is_admin() || !current_user_can( 'manage_woocommerce' ) ) {
        return;
    }
    if ( print_price_wc_is_connected() ) {
        return;
    }
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $page = ( isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '' );
    if ( 'print-price-settings' === $page ) {
        return;
        // Already where the fix happens.
    }
    $last = (string) get_option( 'print_price_widget_last_rendered', '' );
    if ( '' === $last ) {
        return;
        // Never published — the welcome notice is enough.
    }
    $age_days = (time() - (int) strtotime( $last . ' 00:00:00 UTC' )) / DAY_IN_SECONDS;
    if ( $age_days > 30 ) {
        return;
        // Stale install; stop nagging.
    }
    $settings_url = admin_url( 'admin.php?page=print-price-settings' );
    ?>
	<div class="notice notice-error" data-printprice-notice="not-connected">
		<p>
			<strong><?php 
    esc_html_e( 'PrintPrice: your calculator is live but customers cannot order.', 'printprice-3d-calculator' );
    ?></strong>
		</p>
		<p>
			<?php 
    esc_html_e( 'The calculator is showing prices on your site, but PrintPrice is not connected to WooCommerce, so every attempt to add a quote to the cart fails.', 'printprice-3d-calculator' );
    ?>
			<a href="<?php 
    echo esc_url( $settings_url );
    ?>"><?php 
    esc_html_e( 'Connect your store now', 'printprice-3d-calculator' );
    ?></a>
		</p>
	</div>
	<?php 
}

add_action( 'admin_notices', 'print_price_render_setup_notice' );
function print_price_render_setup_notice() {
    if ( !print_price_should_show_setup_notice() ) {
        return;
    }
    $settings_url = admin_url( 'admin.php?page=print-price-settings' );
    ?>
	<div class="notice notice-info is-dismissible" data-printprice-notice="setup">
		<p>
			<strong><?php 
    esc_html_e( 'Welcome to PrintPrice!', 'printprice-3d-calculator' );
    ?></strong>
			<?php 
    esc_html_e( 'Add your WooCommerce REST API keys to start accepting 3D-print orders.', 'printprice-3d-calculator' );
    ?>
			<a href="<?php 
    echo esc_url( $settings_url );
    ?>" class="button button-primary" style="margin-left:8px;">
				<?php 
    esc_html_e( 'Open Settings →', 'printprice-3d-calculator' );
    ?>
			</a>
		</p>
	</div>
	<?php 
}

/**
 * AJAX endpoint: persist the user's choice to dismiss the setup notice.
 * Per-user meta (so a second admin on the same site still gets prompted).
 */
add_action( 'wp_ajax_print_price_dismiss_setup_notice', 'print_price_handle_dismiss_setup_notice' );
function print_price_handle_dismiss_setup_notice() {
    check_ajax_referer( 'print_price_dismiss_setup_notice' );
    if ( !current_user_can( 'manage_woocommerce' ) ) {
        wp_send_json_error( null, 403 );
    }
    update_user_meta( get_current_user_id(), 'print_price_setup_notice_dismissed', 1 );
    wp_send_json_success();
}

/**
 * Top-level "PrintPrice" page — iframes the hosted React admin app
 * from wp.printprice.one. Per WOOCOMMERCE_EXPANSION_PLAN.md §10.2,
 * this keeps premium UI off the WP.org-distributed plugin zip and
 * gives merchants the same dashboard experience as the Shopify side.
 *
 * In dev, override the iframe src by setting PRINT_PRICE_BACKEND_URL
 * to your local Node app.
 */
function print_price_render_dashboard() {
    $license = get_option( 'print_price_license_key', '' );
    $base = print_price_backend_url();
    $settings = admin_url( 'admin.php?page=print-price-settings' );
    if ( empty( $license ) ) {
        echo '<div class="wrap">';
        echo '<h1>' . esc_html__( 'PrintPrice', 'printprice-3d-calculator' ) . '</h1>';
        echo '<div class="notice notice-warning"><p>';
        esc_html_e( 'No license configured yet. ', 'printprice-3d-calculator' );
        printf( '<a href="%s">%s</a>', esc_url( $settings ), esc_html__( 'Open Settings →', 'printprice-3d-calculator' ) );
        echo '</p></div></div>';
        return;
    }
    // Tell the React SPA whether WooCommerce REST keys are configured.
    // The SPA's <SetupBanner> component renders an in-iframe banner
    // pointing back to PrintPrice → Settings when wc_configured=0.
    // We do it via query param (not a backend lookup) so the SPA gets
    // the answer on first paint, before any API calls.
    $wc_configured = ( print_price_wc_is_connected() ? '1' : '0' );
    // The license goes in the URL FRAGMENT, not the query string.
    //
    // A fragment is never sent to the server, so it cannot land in an access
    // log, and browsers strip it from the Referer header, so it cannot leak to
    // a third party either. It used to be ?license=, which wrote a live admin
    // credential into the backend's nginx log on every dashboard load.
    //
    // wc_configured stays in the query: it is a boolean about setup state, not
    // a secret, and the SPA needs it on first paint.
    $iframe_src = add_query_arg( array(
        'wc_configured' => $wc_configured,
    ), $base . '/admin/' ) . '#license=' . rawurlencode( $license );
    echo '<div class="pp-iframe-wrap">';
    printf( '<iframe src="%s" class="pp-iframe" title="PrintPrice"></iframe>', esc_url( $iframe_src ) );
    echo '</div>';
}

/**
 * Settings page — minimal HTML form. License key + backend URL +
 * WooCommerce REST API consumer key / secret.
 */
function print_price_render_settings() {
    if ( !current_user_can( 'manage_woocommerce' ) ) {
        wp_die( esc_html__( 'You do not have permission to access this page.', 'printprice-3d-calculator' ) );
    }
    // "Reset license" button — wipes the option so admin_init regenerates
    // a fresh pp-<uuid> next pageload. Gated to a plugin-specific
    // constant `PRINTPRICE_DEV_TOOLS` that only we add to wp-config.php
    // on test sites — not WP_DEBUG (commonly left on by accident in
    // production) or backend-URL overrides (a customer might legitimately
    // set those). Defense-in-depth: even if someone crafts a POST
    // with the right nonce in production, the handler won't run.
    $reset_allowed = defined( 'PRINTPRICE_DEV_TOOLS' ) && PRINTPRICE_DEV_TOOLS;
    if ( $reset_allowed && isset( $_POST['print_price_reset_license'] ) && check_admin_referer( 'print_price_reset_license', 'print_price_reset_nonce' ) ) {
        update_option( 'print_price_license_key', '' );
        // Clear the public key too, or the shortcode keeps serving a storefront
        // key that points at the OLD identity while wp-admin authenticates as a
        // brand new one — two halves of two different installs.
        update_option( 'print_price_public_key', '' );
        delete_transient( 'print_price_register_backoff' );
        if ( function_exists( 'print_price_ensure_free_identifier' ) ) {
            print_price_ensure_free_identifier();
        }
        echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Free-tier identifier regenerated.', 'printprice-3d-calculator' ) . '</p></div>';
    }
    if ( isset( $_POST['print_price_settings_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['print_price_settings_nonce'] ) ), 'print_price_save_settings' ) ) {
        // print_price_license_key is normally read-only — auto-generated
        // free UUID on activation, replaced by a Freemius license at
        // upgrade. ONLY when PRINTPRICE_DEV_TOOLS is defined (ops/dev
        // only) do we accept the value from the form. The self-heal
        // hook on admin_init will then leave the value alone if it's
        // a recognised shape OR overwrite it back if it isn't — so
        // for ops debug you typically also want to disable the self-
        // heal or use a `pp-` prefix to keep your value persistent.
        if ( defined( 'PRINTPRICE_DEV_TOOLS' ) && PRINTPRICE_DEV_TOOLS && isset( $_POST['print_price_license_key'] ) ) {
            update_option( 'print_price_license_key', trim( sanitize_text_field( wp_unslash( $_POST['print_price_license_key'] ) ) ) );
        }
        update_option( 'print_price_backend_url', trim( esc_url_raw( wp_unslash( $_POST['print_price_backend_url'] ?? '' ) ) ) );
        update_option( 'print_price_wc_consumer_key', trim( sanitize_text_field( wp_unslash( $_POST['print_price_wc_consumer_key'] ?? '' ) ) ) );
        update_option( 'print_price_wc_consumer_secret', trim( sanitize_text_field( wp_unslash( $_POST['print_price_wc_consumer_secret'] ?? '' ) ) ) );
        // Optional cart-URL override. Empty = auto-detect via wc_get_cart_url()
        // at render time (the recommended default). Only a deliberately
        // entered custom URL is stored.
        update_option( 'print_price_cart_url', trim( esc_url_raw( wp_unslash( $_POST['print_price_cart_url'] ?? '' ) ) ) );
        $ck_now = trim( (string) get_option( 'print_price_wc_consumer_key', '' ) );
        $cs_now = trim( (string) get_option( 'print_price_wc_consumer_secret', '' ) );
        if ( empty( $ck_now ) || empty( $cs_now ) ) {
            // Pre-WC-keys save: just store. No point pinging the backend
            // without credentials — it'd just return an error. License
            // is auto-generated by admin_init so we don't need to worry
            // about it.
            echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Settings saved. Add your WooCommerce REST API consumer key and secret below to finish the setup.', 'printprice-3d-calculator' ) . '</p></div>';
        } else {
            // Push WC credentials to the PrintPrice backend so its WooAdapter
            // can talk to this store. The backend probes the key's write
            // permission (via a real webhook registration call); a Read-only
            // key returns 400 with code='wc_key_readonly', which we render
            // here as a focused red notice with actionable steps — vs. a
            // generic yellow "couldn't register" message that merchants tend
            // to gloss over and only discover later when Add-to-Cart fails.
            $connect_result = print_price_push_connection();
            if ( is_wp_error( $connect_result ) ) {
                $err_code = $connect_result->get_error_code();
                $err_msg = $connect_result->get_error_message();
                if ( 'wc_key_readonly' === $err_code ) {
                    $rest_url = admin_url( 'admin.php?page=wc-settings&tab=advanced&section=keys' );
                    echo '<div class="notice notice-error">';
                    echo '<p><strong>' . esc_html__( 'Your WooCommerce REST API key is Read-only.', 'printprice-3d-calculator' ) . '</strong></p>';
                    echo '<p>' . esc_html__( 'PrintPrice needs Read/Write permission to create the quote product on customer checkout and to register order webhooks. Storefront Add-to-Cart will fail with a generic network error until this is fixed.', 'printprice-3d-calculator' ) . '</p>';
                    echo '<ol style="margin-left:1.5em;">';
                    echo '<li>' . sprintf( 
                        /* translators: %s: link to the WC REST API keys settings page */
                        wp_kses( __( 'Open <a href="%s">WooCommerce → Settings → Advanced → REST API</a>.', 'printprice-3d-calculator' ), array(
                            'a' => array(
                                'href' => true,
                            ),
                        ) ),
                        esc_url( $rest_url )
                     ) . '</li>';
                    echo '<li>' . esc_html__( 'Click the existing PrintPrice key (or "Add key" if none exists) and change Permissions to Read/Write.', 'printprice-3d-calculator' ) . '</li>';
                    echo '<li>' . esc_html__( 'Save. Copy the consumer key and secret, paste them below, and Save Changes here again.', 'printprice-3d-calculator' ) . '</li>';
                    echo '</ol>';
                    echo '</div>';
                } elseif ( 0 === strpos( $err_code, 'wc_key_' ) ) {
                    // Any other fatal key problem the backend identified —
                    // today wc_key_invalid (credentials not recognised) and
                    // wc_key_insufficient_role (the key's WordPress user can't
                    // manage WooCommerce). Both break storefront Add-to-Cart
                    // exactly like a Read-only key does, so they get the same
                    // red, non-dismissible treatment rather than the yellow
                    // "couldn't register" notice merchants gloss over. The
                    // backend has already narrowed it to one cause and worded
                    // the fix, so render its message rather than guessing here.
                    $rest_url = admin_url( 'admin.php?page=wc-settings&tab=advanced&section=keys' );
                    echo '<div class="notice notice-error">';
                    echo '<p><strong>' . esc_html__( 'PrintPrice cannot use your WooCommerce REST API key.', 'printprice-3d-calculator' ) . '</strong></p>';
                    echo '<p>' . esc_html( $err_msg ) . '</p>';
                    echo '<p>' . sprintf( 
                        /* translators: %s: link to the WC REST API keys settings page */
                        wp_kses( __( 'Open <a href="%s">WooCommerce → Settings → Advanced → REST API</a>, then paste the key below and Save Changes here again.', 'printprice-3d-calculator' ), array(
                            'a' => array(
                                'href' => true,
                            ),
                        ) ),
                        esc_url( $rest_url )
                     ) . '</p>';
                    echo '</div>';
                } else {
                    echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html__( 'Settings saved, but couldn\'t register with PrintPrice backend: ', 'printprice-3d-calculator' ) . esc_html( $err_msg ) . '</p></div>';
                }
            } else {
                $registered = print_price_has_public_key() || print_price_register_keys();
                if ( $registered ) {
                    echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Settings saved and store registered with PrintPrice.', 'printprice-3d-calculator' ) . '</p></div>';
                } else {
                    echo '<div class="notice notice-warning"><p>' . esc_html__( 'Your store connection was verified, but PrintPrice could not finish securing this installation. Reload this page to retry.', 'printprice-3d-calculator' ) . '</p></div>';
                }
            }
        }
    }
    $license = get_option( 'print_price_license_key', '' );
    $backend = get_option( 'print_price_backend_url', '' );
    $ck = get_option( 'print_price_wc_consumer_key', '' );
    $cs = get_option( 'print_price_wc_consumer_secret', '' );
    $cart_override = get_option( 'print_price_cart_url', '' );
    // Auto-detected cart URL from WooCommerce (respects a custom cart page
    // / slug). Shown to the merchant so they can see what the widget will
    // use when no override is set.
    $cart_detected = ( function_exists( 'wc_get_cart_url' ) ? wc_get_cart_url() : '' );
    ?>
	<div class="wrap">
		<h1><?php 
    esc_html_e( 'PrintPrice — Settings', 'printprice-3d-calculator' );
    ?></h1>
		<form method="post">
			<?php 
    wp_nonce_field( 'print_price_save_settings', 'print_price_settings_nonce' );
    ?>
			<table class="form-table" role="presentation">
				<tr>
					<th scope="row"><?php 
    esc_html_e( 'PrintPrice identifier', 'printprice-3d-calculator' );
    ?></th>
					<td>
						<?php 
    if ( 0 === strpos( (string) $license, 'pp-' ) ) {
        $tier_label = __( 'Free tier', 'printprice-3d-calculator' );
    } elseif ( !empty( $license ) ) {
        $tier_label = __( 'Paid (Freemius license)', 'printprice-3d-calculator' );
    } else {
        $tier_label = __( 'Not yet generated', 'printprice-3d-calculator' );
    }
    // Editable for dev/support only — same gate as the
    // reset button. Lets ops swap identities to view a
    // merchant's data for troubleshooting. Real customers
    // see the value as a read-only pill below.
    $dev_tools = defined( 'PRINTPRICE_DEV_TOOLS' ) && PRINTPRICE_DEV_TOOLS;
    ?>
						<?php 
    if ( $dev_tools ) {
        ?>
							<input name="print_price_license_key" id="pp_license"
								type="text" class="regular-text code"
								value="<?php 
        echo esc_attr( $license );
        ?>" />
							<span style="margin-left:8px;color:#646970;"><?php 
        echo esc_html( $tier_label );
        ?></span>
							<p class="description" style="color:#b32d2e;">
								<strong><?php 
        esc_html_e( '⚠ Editable because PRINTPRICE_DEV_TOOLS is defined in wp-config.php.', 'printprice-3d-calculator' );
        ?></strong>
								<?php 
        esc_html_e( 'Production installs do not show this field as editable. Changing the value here points the dashboard at a different merchant\'s data — useful for support/debug, breaks everything otherwise.', 'printprice-3d-calculator' );
        ?>
							</p>
						<?php 
    } else {
        ?>
							<code style="font-family:monospace;font-size:13px;background:#f0f0f1;padding:4px 8px;border-radius:3px;">
								<?php 
        echo esc_html( ( $license ?: '—' ) );
        ?>
							</code>
							<span style="margin-left:8px;color:#646970;"><?php 
        echo esc_html( $tier_label );
        ?></span>
							<p class="description">
								<?php 
        esc_html_e( 'Managed automatically: a free-tier UUID is generated on activation, and replaced with a Freemius license key when you upgrade. Use the Reset button below if you need to start over.', 'printprice-3d-calculator' );
        ?>
							</p>
						<?php 
    }
    ?>
					</td>
				</tr>
				<tr>
					<th scope="row"><label for="pp_backend"><?php 
    esc_html_e( 'Backend URL (advanced)', 'printprice-3d-calculator' );
    ?></label></th>
					<td>
						<input name="print_price_backend_url" id="pp_backend" type="url" class="regular-text" value="<?php 
    echo esc_attr( $backend );
    ?>" placeholder="https://wp.printprice.one" />
						<p class="description" style="color:#b32d2e;">
							<strong><?php 
    esc_html_e( '⚠ Leave this blank.', 'printprice-3d-calculator' );
    ?></strong>
							<?php 
    esc_html_e( 'This is an advanced override. Setting it will redirect every price calculation, file upload, and order webhook to a different server — incorrect values will break your calculator on the storefront and break new-order syncing in the admin. Only change this if PrintPrice support has explicitly asked you to.', 'printprice-3d-calculator' );
    ?>
						</p>
					</td>
				</tr>
				<tr>
					<th scope="row"><label for="pp_cart_url"><?php 
    esc_html_e( 'Cart URL', 'printprice-3d-calculator' );
    ?></label></th>
					<td>
						<p style="margin:0 0 6px;">
							<?php 
    esc_html_e( 'Auto-detected from WordPress:', 'printprice-3d-calculator' );
    ?>
							<code style="font-family:monospace;font-size:13px;background:#f0f0f1;padding:4px 8px;border-radius:3px;">
								<?php 
    echo esc_html( ( $cart_detected ?: '—' ) );
    ?>
							</code>
						</p>
						<input name="print_price_cart_url" id="pp_cart_url" type="url" class="regular-text" value="<?php 
    echo esc_attr( $cart_override );
    ?>" placeholder="<?php 
    echo esc_attr( $cart_detected );
    ?>" />
						<p class="description" style="color:#b32d2e;">
							<strong><?php 
    esc_html_e( '⚠ Leave this blank.', 'printprice-3d-calculator' );
    ?></strong>
							<?php 
    esc_html_e( 'The cart URL above is detected automatically from your WordPress settings and is correct for almost every store. Only fill this in to override it, and only with extreme caution: after a customer adds a quote to the cart they are sent here, so a wrong value sends them to a broken page. Leave blank to always use the auto-detected URL.', 'printprice-3d-calculator' );
    ?>
						</p>
					</td>
				</tr>
				<tr>
					<th scope="row" colspan="2" style="padding-top:24px;">
						<h2 style="margin:0;font-size:14px;"><?php 
    esc_html_e( 'WooCommerce connection', 'printprice-3d-calculator' );
    ?></h2>
					</th>
				</tr>
				<tr>
					<td colspan="2" style="padding:0 10px 12px;">
						<div class="notice notice-info inline" style="margin:0;padding:10px 12px;">
							<p style="margin:0;">
								<strong><?php 
    esc_html_e( 'How this works:', 'printprice-3d-calculator' );
    ?></strong>
								<?php 
    esc_html_e( 'PrintPrice talks to your store via WooCommerce\'s REST API to create the quote product on Add-to-Cart and to listen for order webhooks. Both require a key with', 'printprice-3d-calculator' );
    ?>
								<strong><?php 
    esc_html_e( 'Read/Write', 'printprice-3d-calculator' );
    ?></strong>
								<?php 
    esc_html_e( 'permission.', 'printprice-3d-calculator' );
    ?>
							</p>
							<p style="margin:6px 0 0;">
								<strong><?php 
    esc_html_e( '(Do this only if you created the key with read permission only at first) Already changed the key\'s permissions in WooCommerce?', 'printprice-3d-calculator' );
    ?></strong>
								<?php 
    esc_html_e( 'Click Save Changes below, that\'s what re-registers the order webhooks on your store with the updated permission. Updating the key in WooCommerce alone is not enough.', 'printprice-3d-calculator' );
    ?>
							</p>
						</div>
					</td>
				</tr>
				<tr>
					<th scope="row"><label for="pp_ck"><?php 
    esc_html_e( 'WooCommerce REST consumer key', 'printprice-3d-calculator' );
    ?></label></th>
					<td>
						<input name="print_price_wc_consumer_key" id="pp_ck" type="text" class="regular-text code" value="<?php 
    echo esc_attr( $ck );
    ?>" />
						<p class="description">
							<?php 
    $wc_rest_url = admin_url( 'admin.php?page=wc-settings&tab=advanced&section=keys' );
    echo wp_kses( sprintf( 
        /* translators: %s: link to WooCommerce REST API keys settings page */
        __( 'Generate in <a href="%s">WooCommerce → Settings → Advanced → REST API → Add Key</a>.', 'printprice-3d-calculator' ),
        esc_url( $wc_rest_url )
     ), array(
        'a' => array(
            'href' => true,
        ),
    ) );
    ?>
							<strong><?php 
    esc_html_e( 'Permissions must be Read/Write.', 'printprice-3d-calculator' );
    ?></strong>
						</p>
					</td>
				</tr>
				<tr>
					<th scope="row"><label for="pp_cs"><?php 
    esc_html_e( 'WooCommerce REST consumer secret', 'printprice-3d-calculator' );
    ?></label></th>
					<td>
						<input name="print_price_wc_consumer_secret" id="pp_cs" type="password" class="regular-text code" value="<?php 
    echo esc_attr( $cs );
    ?>" />
						<p class="description">
							<?php 
    esc_html_e( 'Shown once when you generate the key. If you lost it, regenerate the key in WooCommerce.', 'printprice-3d-calculator' );
    ?>
						</p>
					</td>
				</tr>
			</table>
			<?php 
    submit_button();
    ?>
		</form>

		<?php 
    // Reset button is a dev/support tool — generating a fresh
    // identifier orphans the merchant's existing data on the
    // backend (materials, profit margins, orders all key on the
    // old merchantId). Hidden in production. Production merchants
    // who genuinely need a reset go through support, which can
    // also clean up the backend side properly.
    $show_reset_button = defined( 'PRINTPRICE_DEV_TOOLS' ) && PRINTPRICE_DEV_TOOLS;
    if ( $show_reset_button && 0 === strpos( (string) $license, 'pp-' ) ) {
        ?>
			<h2><?php 
        esc_html_e( 'Advanced (dev only)', 'printprice-3d-calculator' );
        ?></h2>
			<form method="post" style="margin-top:8px;" onsubmit="return confirm('<?php 
        echo esc_js( __( 'Generate a fresh free-tier identifier? Your existing data on the server (materials, profit margins, orders) stays keyed to the OLD identifier and will no longer be visible. Do not do this on a production site.', 'printprice-3d-calculator' ) );
        ?>');">
				<?php 
        wp_nonce_field( 'print_price_reset_license', 'print_price_reset_nonce' );
        ?>
				<button type="submit" name="print_price_reset_license" value="1" class="button button-secondary"><?php 
        esc_html_e( 'Reset free-tier identifier', 'printprice-3d-calculator' );
        ?></button>
				<p class="description" style="margin-top:6px;"><?php 
        esc_html_e( 'Visible because PRINTPRICE_DEV_TOOLS is defined in wp-config.php. Production installs do not set this.', 'printprice-3d-calculator' );
        ?></p>
			</form>
		<?php 
    }
    ?>

		<?php 
    if ( $license && $backend ) {
        ?>
			<h2><?php 
        esc_html_e( 'Connection check', 'printprice-3d-calculator' );
        ?></h2>
			<p>
				<button class="button" id="pp-connect-test"><?php 
        esc_html_e( 'Test connection', 'printprice-3d-calculator' );
        ?></button>
				<span id="pp-connect-status" style="margin-left:8px;"></span>
			</p>
			<?php 
        /* Click handler lives in assets/admin-settings.js and is
         * enqueued by print_price_enqueue_admin_assets() on this
         * screen. The backend URL is passed via wp_localize_script
         * as window.PrintPriceSettings.backend. */
        ?>
		<?php 
    }
    ?>
	</div>
	<?php 
}

// ---------------------------------------------------------------------------
// Server-side: push WC credentials to our backend
// ---------------------------------------------------------------------------
/**
 * POST the merchant's WC REST credentials to {backend}/api/woo/connect.
 * Called whenever Settings are saved. Returns true on success or a
 * WP_Error on any failure (so the Settings UI can surface it).
 */
function print_price_push_connection() {
    $license = get_option( 'print_price_license_key', '' );
    $ck = get_option( 'print_price_wc_consumer_key', '' );
    $cs = get_option( 'print_price_wc_consumer_secret', '' );
    $base = print_price_backend_url_server();
    if ( empty( $license ) ) {
        return new WP_Error('no_license', __( 'PrintPrice identifier not generated yet — try reloading the page.', 'printprice-3d-calculator' ));
    }
    if ( empty( $ck ) || empty( $cs ) ) {
        return new WP_Error('no_wc_keys', __( 'WooCommerce REST API consumer key and secret are required.', 'printprice-3d-calculator' ));
    }
    $site_url = home_url();
    // Inside Colima/wp-env, our backend can't reach back to localhost:8888
    // — but a real Woo store will be a public URL. For local dev we still
    // pass the URL through; the only consumer is WooAdapter when the user
    // later triggers a calc/add-to-cart, and dev mode will surface errors
    // to logs instead of breaking save.
    //
    // siteAdminEmail + siteName: pass through so the backend can email the
    // merchant directly when something needs their attention (e.g. WC REST
    // credentials silently rejecting product creation on checkout). The
    // admin email is what WP itself uses for plugin/system notifications,
    // and every install has it set during the WP setup flow. Site name is
    // purely cosmetic — used to address the merchant by their shop name
    // in outgoing emails.
    $admin_email = get_option( 'admin_email' );
    $site_name = get_bloginfo( 'name' );
    $response = wp_remote_post( $base . '/api/woo/connect', array(
        'timeout' => 10,
        'headers' => array(
            'Content-Type'         => 'application/json',
            'X-Printprice-License' => $license,
        ),
        'body'    => wp_json_encode( array(
            'siteUrl'        => $site_url,
            'consumerKey'    => $ck,
            'consumerSecret' => $cs,
            'siteAdminEmail' => $admin_email,
            'siteName'       => $site_name,
        ) ),
    ) );
    if ( is_wp_error( $response ) ) {
        return $response;
    }
    $code = wp_remote_retrieve_response_code( $response );
    if ( $code < 200 || $code >= 300 ) {
        // The backend returns structured errors like
        //   { "success": false, "code": "wc_key_readonly", "message": "..." }
        // Parse those so the merchant sees the human-readable message
        // instead of a raw JSON blob. Fall back to the raw body if it
        // isn't JSON or doesn't have the expected shape.
        $body = wp_remote_retrieve_body( $response );
        $decoded = json_decode( $body, true );
        $message = ( is_array( $decoded ) && !empty( $decoded['message'] ) ? (string) $decoded['message'] : $body );
        $err_code = ( is_array( $decoded ) && !empty( $decoded['code'] ) ? (string) $decoded['code'] : 'connect_failed' );
        if ( 409 === $code && 'existing_install_requires_recovery' === $err_code ) {
            print_price_rejoin_note( 'needs_support', 'backend protected an existing installation for this site' );
        }
        return new WP_Error($err_code, $message, array(
            'status' => $code,
        ));
    }
    update_option( 'print_price_last_connected_site_url', untrailingslashit( esc_url_raw( $site_url ) ) );
    delete_option( 'print_price_site_url_change_confirmed_at' );
    return true;
}

/** Record that an administrator intentionally changed this WordPress address. */
function print_price_note_site_url_change(  $old_value, $new_value  ) {
    if ( !current_user_can( 'manage_options' ) ) {
        return;
    }
    $old_url = untrailingslashit( esc_url_raw( (string) $old_value ) );
    $new_url = untrailingslashit( esc_url_raw( (string) $new_value ) );
    if ( '' !== $new_url && $old_url !== $new_url ) {
        update_option( 'print_price_site_url_change_confirmed_at', time(), false );
        delete_transient( 'print_price_connection_url_sync_backoff' );
    }
}

add_action(
    'update_option_home',
    'print_price_note_site_url_change',
    10,
    2
);
add_action(
    'update_option_siteurl',
    'print_price_note_site_url_change',
    10,
    2
);
/**
 * Keep the backend connection bound to this install after an intentional
 * WordPress address change.
 *
 * A cross-domain redirect is not proof that the redirect target owns the
 * WooCommerce credentials. The plugin itself is the trusted source: this call
 * is authenticated with its PrintPrice admin credential and the existing
 * connect flow submits the WooCommerce key directly from WordPress, validates
 * it against the new home_url(), and recreates the order webhooks.
 *
 * The local option avoids a network request on ordinary admin pageloads. Older
 * installs do not have it, so they ask the authenticated GET /connect endpoint
 * once and seed it when the backend already agrees.
 *
 * @return bool True when the stored URL already agrees or was updated.
 */
function print_price_maybe_sync_connection_url() {
    if ( !current_user_can( 'manage_woocommerce' ) ) {
        return false;
    }
    if ( function_exists( 'wp_get_environment_type' ) && 'production' !== wp_get_environment_type() ) {
        return false;
    }
    $confirmed_at = (int) get_option( 'print_price_site_url_change_confirmed_at', 0 );
    $owner_confirmed = $confirmed_at > 0 && time() - $confirmed_at <= DAY_IN_SECONDS;
    if ( !$owner_confirmed && function_exists( 'print_price_fs' ) ) {
        $fs = print_price_fs();
        if ( is_object( $fs ) && method_exists( $fs, 'is_clone' ) && $fs->is_clone() ) {
            return false;
        }
    }
    $license = (string) get_option( 'print_price_license_key', '' );
    $ck = (string) get_option( 'print_price_wc_consumer_key', '' );
    $cs = (string) get_option( 'print_price_wc_consumer_secret', '' );
    $current = untrailingslashit( esc_url_raw( home_url() ) );
    if ( '' === $license || '' === $ck || '' === $cs || '' === $current ) {
        return false;
    }
    $known = untrailingslashit( esc_url_raw( (string) get_option( 'print_price_last_connected_site_url', '' ) ) );
    if ( '' !== $known && $known === $current ) {
        delete_option( 'print_price_site_url_change_confirmed_at' );
        return true;
    }
    if ( get_transient( 'print_price_connection_url_sync_backoff' ) ) {
        return false;
    }
    set_transient( 'print_price_connection_url_sync_backoff', 1, HOUR_IN_SECONDS );
    // Existing installs predate the local marker. Ask our authenticated backend
    // what it has before deciding that a reconnect is necessary.
    if ( '' === $known ) {
        $response = wp_remote_get( print_price_backend_url_server() . '/api/woo/connect', array(
            'timeout' => 10,
            'headers' => array(
                'X-Printprice-License' => $license,
            ),
        ) );
        if ( !is_wp_error( $response ) ) {
            $code = wp_remote_retrieve_response_code( $response );
            $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
            $stored = ( is_array( $decoded ) && !empty( $decoded['data']['siteUrl'] ) ? untrailingslashit( esc_url_raw( (string) $decoded['data']['siteUrl'] ) ) : '' );
            if ( $code >= 200 && $code < 300 && $stored === $current ) {
                update_option( 'print_price_last_connected_site_url', $current );
                delete_option( 'print_price_site_url_change_confirmed_at' );
                return true;
            }
        }
    }
    $result = print_price_push_connection();
    if ( is_wp_error( $result ) ) {
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            error_log( '[PrintPrice] Could not update the backend after the WordPress address changed: ' . $result->get_error_message() );
        }
        return false;
    }
    return true;
}

// Runs only when an administrator visits wp-admin. A successful settings save
// stores the local URL, so ordinary pageloads return before any network request.
add_action( 'admin_init', 'print_price_maybe_sync_connection_url', 20 );
// ---------------------------------------------------------------------------
// Storefront — shortcode + widget script tag
// ---------------------------------------------------------------------------
/**
 * Usage on a WooCommerce page or post:
 *   [print_price_widget]
 *
 * Renders an empty container plus a script tag that loads our hosted
 * widget bundle. The bundle reads the `data-public-key` attr and the
 * `data-backend` attr to know where to send calc requests.
 */
add_shortcode( 'print_price_widget', 'print_price_widget_shortcode' );
/**
 * Register the Gutenberg block "PrintPrice Calculator" so merchants can
 * drag the widget straight from the block inserter instead of pasting a
 * shortcode. Server-side rendered — render_callback just delegates to
 * the existing shortcode function so both insertion paths emit the
 * same HTML.
 */
add_action( 'init', function () {
    register_block_type( __DIR__ . '/blocks/calculator', array(
        'render_callback' => function () {
            return print_price_widget_shortcode( array() );
        },
    ) );
} );
function print_price_widget_shortcode(  $atts  ) {
    $license = (string) get_option( 'print_price_license_key', '' );
    $backend = print_price_backend_url();
    if ( empty( $license ) ) {
        return '<div class="printprice-widget printprice-widget--unconfigured" style="padding:1em;border:1px dashed #999;">' . esc_html__( 'PrintPrice is not configured. The site administrator must set a license key in PrintPrice → Settings.', 'printprice-3d-calculator' ) . '</div>';
    }
    $container_id = 'printprice-widget-' . wp_generate_uuid4();
    // Record that the calculator is actually LIVE on the storefront. Without
    // this we cannot tell "installed and idle" from "published and taking real
    // customers", and those need very different warnings: three production
    // stores ran the calculator for weeks without ever connecting WooCommerce,
    // so every shopper hit a dead Add-to-Cart. Store a date (not a bool) so a
    // stale install stops nagging on its own.
    $today = gmdate( 'Y-m-d' );
    if ( get_option( 'print_price_widget_last_rendered' ) !== $today ) {
        update_option( 'print_price_widget_last_rendered', $today, false );
    }
    // Owner-only warning, rendered ABOVE the widget when WooCommerce is not
    // connected. Shoppers never see it — they would only be confused by it, and
    // they cannot act on it. The person who can fix it is the one who sees it.
    $owner_warning = '';
    if ( !print_price_wc_is_connected() && current_user_can( 'manage_woocommerce' ) ) {
        $owner_warning = sprintf(
            '<div class="printprice-widget__owner-warning" style="margin:0 0 1em;padding:12px 14px;border-left:4px solid #d63638;background:#fcf0f1;font-size:14px;line-height:1.5">' . '<strong>%1$s</strong> %2$s <a href="%3$s">%4$s</a><br><em style="opacity:0.75">%5$s</em></div>',
            esc_html__( 'PrintPrice is not connected to WooCommerce.', 'printprice-3d-calculator' ),
            esc_html__( 'Customers can get a price here, but adding it to the cart will fail.', 'printprice-3d-calculator' ),
            esc_url( admin_url( 'admin.php?page=print-price-settings' ) ),
            esc_html__( 'Connect your store', 'printprice-3d-calculator' ),
            esc_html__( 'Only you can see this message, because you can manage WooCommerce.', 'printprice-3d-calculator' )
        );
    }
    // Enqueue widget.js via wp_enqueue_script so WordPress can manage
    // dependencies, async/defer, and the ?ver= cache bust correctly.
    // This is the WP.org-required pattern (vs inline <script src=...>).
    $handle = 'print-price-widget';
    if ( !wp_script_is( $handle, 'registered' ) ) {
        wp_register_script(
            $handle,
            $backend . '/widget.js',
            array(),
            PRINT_PRICE_VERSION,
            array(
                'in_footer' => true,
                'strategy'  => 'async',
            )
        );
    }
    wp_enqueue_script( $handle );
    // data-locale drives the widget's 18-language storefront i18n (WP site
    // language). data-viewer-src / data-occt-src / data-occt-wasm point at the
    // backend's static assets; the widget lazy-loads the viewer bundle (and, for
    // STEP/STP files, the occt kernel) from these only when a customer previews
    // a model, so page-load weight is unchanged.
    //
    // data-cart-url is where the widget sends the customer after Add-to-Cart.
    // Prefer the merchant's explicit override (Settings → Cart URL); otherwise
    // auto-detect via wc_get_cart_url() so a custom cart page / slug is
    // respected. The widget falls back to '/cart' if this is empty.
    $cart_override = trim( (string) get_option( 'print_price_cart_url', '' ) );
    $cart_url = ( $cart_override !== '' ? $cart_override : (( function_exists( 'wc_get_cart_url' ) ? wc_get_cart_url() : '' )) );
    // THE credential must not appear here. This markup is public product-page
    // HTML: whatever goes in is readable with view-source by anyone on the
    // internet. data-public-key is built for that — it reaches only the six
    // storefront endpoints a shopper needs.
    //
    // data-license is the transitional fallback for the window between this
    // plugin updating and its first admin pageload completing registration.
    // It is the old, already-published value, so it leaks nothing that was not
    // already out; it disappears the moment the swap lands. Remove this branch
    // once no install reports an empty public key.
    $public_key = (string) get_option( 'print_price_public_key', '' );
    $key_attr = ( '' !== $public_key ? sprintf( 'data-public-key="%s"', esc_attr( $public_key ) ) : sprintf( 'data-license="%s"', esc_attr( $license ) ) );
    return $owner_warning . sprintf(
        '<div id="%1$s" class="printprice-widget" %2$s data-backend="%3$s" data-locale="%4$s" data-viewer-src="%5$s" data-occt-src="%6$s" data-occt-wasm="%7$s" data-cart-url="%8$s"></div>',
        esc_attr( $container_id ),
        $key_attr,
        esc_url( $backend ),
        esc_attr( get_locale() ),
        esc_url( $backend . '/viewer3d.js' ),
        esc_url( $backend . '/occt-import-js.js' ),
        esc_url( $backend . '/occt-import-js.wasm' ),
        esc_url( $cart_url )
    );
}

// ---------------------------------------------------------------------------
// WooCommerce order admin — order details metabox
// ---------------------------------------------------------------------------
add_action( 'add_meta_boxes', 'print_price_register_order_metabox' );
function print_price_register_order_metabox() {
    $screens = array('shop_order', 'woocommerce_page_wc-orders');
    foreach ( $screens as $screen ) {
        add_meta_box(
            'printprice_order_details',
            __( 'PrintPrice', 'printprice-3d-calculator' ),
            'print_price_render_order_metabox',
            $screen,
            'side',
            'default'
        );
    }
}

function print_price_render_order_metabox(  $post_or_order  ) {
    $order_id = ( is_object( $post_or_order ) && method_exists( $post_or_order, 'get_id' ) ? $post_or_order->get_id() : (( is_object( $post_or_order ) ? $post_or_order->ID : 0 )) );
    $license = (string) get_option( 'print_price_license_key', '' );
    $backend = print_price_backend_url();
    if ( empty( $license ) ) {
        echo '<p>' . esc_html__( 'PrintPrice not configured.', 'printprice-3d-calculator' ) . '</p>';
        return;
    }
    $handle = 'print-price-order-block';
    if ( !wp_script_is( $handle, 'registered' ) ) {
        wp_register_script(
            $handle,
            $backend . '/order-block.js',
            array(),
            PRINT_PRICE_VERSION,
            array(
                'in_footer' => true,
                'strategy'  => 'async',
            )
        );
    }
    wp_enqueue_script( $handle );
    // The license, not the public key, and that is correct here: this metabox
    // renders on the WooCommerce order screen, which already requires an
    // order-editing capability. It needs admin-only endpoints the storefront
    // key deliberately cannot reach. Do NOT copy the storefront's public-key
    // pattern into this block — and do not copy this line into anything a
    // shopper can load.
    printf(
        '<div id="printprice-order-block-%1$s" data-order-id="%1$s" data-license="%2$s" data-backend="%3$s"></div>',
        esc_attr( $order_id ),
        esc_attr( $license ),
        esc_url( $backend )
    );
}
