<?php

declare(strict_types=1);

namespace Instinctor\Core;

if ( ! defined( 'ABSPATH' ) ) exit;

use Instinctor\Admin\AdminController;
use Instinctor\Core\Config;
use Instinctor\Core\DB;
use Instinctor\Core\Migration;
use Instinctor\Frontend\FrontendController;
use Instinctor\Api\RestController;

/**
 * Main plugin orchestrator
 */
final class Plugin
{
    private static ?Plugin $instance = null;
    private bool $initialized = false;

    /**
     * Check if current site is instinctor.com main site (all addons unlocked)
     * Note: Subdomains like demo.instinctor.com require paid activation
     */
    public static function isInstinctorApp(): bool
    {
        $site_url = strtolower(get_site_url());
        $host = wp_parse_url($site_url, PHP_URL_HOST);
        
        // Only main instinctor.com domain - NOT subdomains like demo.instinctor.com
        return (
            $host === 'instinctor.com' ||
            $host === 'www.instinctor.com' ||
            $host === 'instinctor.local' ||
            $host === 'www.instinctor.local'
        );
    }

        // ── Site classification: SINGLE SOURCE OF TRUTH ──────────────────────
    // ALWAYS use these helpers. NEVER compare Config::get('instinctor_site_mode')
    // to a raw string, and never hard-code 'client_site' / 'license' at a call
    // site. Provisioning writes 'client_site' (PlatformService) or 'license'
    // (the license-activation flow); a consumer that hard-codes the other value
    // silently disagrees. That exact drift hid a broken Instagent on keyless
    // client sites for who-knows-how-long — getLicenseKey() checked
    // === 'client_site' while every client install actually stored 'license',
    // so the AI reply was suppressed with no error. Centralised 2026-07-08.
    public const SITE_MODE_CLIENT  = 'client_site';
    public const SITE_MODE_LICENSE = 'license';

    /** Raw provisioning mode. Defaults to 'license' when unset. */
    public static function siteMode(): string
    {
        return (string) Config::get('instinctor_site_mode', self::SITE_MODE_LICENSE);
    }

    /** True when provisioned as a managed client site (PlatformService flow). */
    public static function isClientSite(): bool
    {
        return self::siteMode() === self::SITE_MODE_CLIENT;
    }

    /**
     * True when this install is part of the VERTICAL product: instinctor.com itself (where
     * free instinctor.com/<slug> pages live), or a standalone we provisioned for a vertical
     * owner who upgraded. Only these carry the quote / estimate / invoice / vehicle
     * plumbing. Bespoke client sites (aokarate.com, frankiesands.com, ...) never set the
     * flag, so that vertical functionality never appears on them. The vertical product
     * path is: see it at instinctor.com -> free page at instinctor.com/<name> -> upgrade
     * -> standalone at <name>.instinctor.com (provisioning sets instinctor_is_vertical_site).
     */
    public static function isVerticalSite(): bool
    {
        return self::isInstinctorApp()
            || (string) Config::get('instinctor_is_vertical_site', '0') === '1';
    }

    /** Runtime mode: plugin runs as a standalone app, not inside WordPress. */
    public static function isStandalone(): bool
    {
        return defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone';
    }

    /** Runtime mode: plugin runs inside WordPress (the default when unset). */
    public static function isWordPress(): bool
    {
        return ! self::isStandalone();
    }

    /**
     * The mobile control panel on a WordPress install.
     *
     * WordPress owns the URL space, so these paths have to be claimed before the
     * query is resolved or WP answers with the front page: /dashboard/app on
     * bathdone.com rendered the home page with a 200, which reads as "the app is
     * broken" rather than "not enabled here" (found 2026-08-13).
     *
     * Off by default. The designer turns it on per site in Settings, which is
     * also what decides whether the owner's sign-in link resolves at all.
     */
    private static function routePanel(string $path): void
    {
        if (strpos($path, '/dashboard') !== 0) return;
        if (!class_exists('\Instinctor\Services\OwnerAccess')
            || !\Instinctor\Services\OwnerAccess::enabled()) {
            return;
        }
        $c = '\\Instinctor\\Frontend\\TenantDashboardController';
        $routes = [
            '/dashboard/panel-login'      => [$c, 'panelLogin'],
            '/dashboard/app'              => [$c, 'app'],
            '/dashboard/app/manifest'     => [$c, 'appManifest'],
            '/dashboard/app/sw.js'        => [$c, 'appSw'],
            '/dashboard/app/requests'     => [$c, 'appRequests'],
            '/dashboard/app/chats'        => [$c, 'appChats'],
            '/dashboard/app/chat'         => [$c, 'appChat'],
            '/dashboard/app/chat-send'    => [$c, 'appChatSend'],
            '/dashboard/app/chat-draft'   => [$c, 'appChatDraft'],
            '/dashboard/app/photo'        => [$c, 'appPhoto'],
            '/dashboard/app/photo-add'    => [$c, 'appPhotoAdd'],
            '/dashboard/app/quote'        => [$c, 'appQuote'],
            '/dashboard/app/quote-status' => [$c, 'appQuoteStatus'],
            '/dashboard/app/booking'      => [$c, 'appBooking'],
            '/dashboard/app/booking-act'  => [$c, 'appBookingAct'],
            '/dashboard/app/close'        => [$c, 'appClose'],
            '/dashboard/app/services'     => [$c, 'appServices'],
            '/dashboard/app/svc-price'    => [$c, 'appSvcPrice'],
            '/dashboard/app/more'         => [$c, 'appMore'],
            '/dashboard/app/qr'           => [$c, 'appQr'],
        ];
        if (!isset($routes[$path])) return;
        [$class, $method] = $routes[$path];
        if (!class_exists($class) || !method_exists($class, $method)) return;
        $class::$method();
        exit;
    }

    public static function instance(): Plugin
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct()
    {
        $this->init();
    }

    private function init(): void
    {
        if ($this->initialized) {
            return;
        }

        // Handle plugin upgrades - runs on every load to catch upgrades
        $this->handleUpgrade();

        // One-time migrations
        $this->runMigrations();

        // 3.18.56 — Copy Impulse page data from wp_postmeta → instinctor_pages (once)
        \Instinctor\Services\PageService::migrateFromWordPress();

        // v2.9.893 Initialize Tier Manager (must be early - sets addon compat)
        TierManager::instance();

        // 4.2 — Register all asset handles up front. WP context: hooks into
        // wp_enqueue_scripts / admin_enqueue_scripts so handles are known
        // before any template calls Assets::enqueue(). Standalone context:
        // run synchronously since shim's registry is global.
        if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
            Assets::registerAll();
        } else {
            add_action('wp_enqueue_scripts', [Assets::class, 'registerAll'], 0);
            add_action('admin_enqueue_scripts', [Assets::class, 'registerAll'], 0);
        }

        // Register custom cron schedules
        add_filter('cron_schedules', [$this, 'addCronSchedules']);
        
        // Register cron hooks (legacy)
        add_action('instinctor_check_low_stock', [$this, 'checkLowStock']);

        // Initialize v1.3.0 cron service
        \Instinctor\Services\CronService::init();

        // T9 — Free registration notice (non-blocking, shows until email registered)
        \Instinctor\Core\RegistrationNotice::init();

        // WP.org A3 — first-admin-visit platform-consent banner (WP-mode only,
        // see PlatformConsent + PlatformConsentNotice). Standalone short-circuits.
        \Instinctor\Core\PlatformConsentNotice::init();

        // T9b — Designer program notice removed 2.1.413 (marketplace teardown).

        // T10 — Standalone admin router (no-op on WordPress)
        // On standalone, pre-register ALL ajax handlers before AdminRouter::dispatch()
        // fires — services registered below via init() only do so for admin-page loads,
        // not for admin-ajax requests (which skip past those init() branches). Without
        // this, every chat / analytics / store admin-ajax call 404s with "Unknown action".
        if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
            $reqPath = wp_parse_url( sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '/')), PHP_URL_PATH );
            if ($reqPath === '/admin/admin-ajax.php') {
                new \Instinctor\Admin\AdminController();
                // Boot every service that registers ajax handlers so its
                // registerAjax() calls run before AjaxDispatcher::dispatch().
                foreach ([
                    '\Instinctor\Services\ChatService',
                    '\Instinctor\Services\AIContentService',
                    '\Instinctor\Services\AdvancedAnalyticsService',
                    '\Instinctor\Services\UpsellService',
                    '\Instinctor\Services\DigitalDownloadService',
                    // Image library / optimizer register instinctor_imglib_* and
                    // instinctor_imgopt_* handlers — without pre-boot, picker + upload
                    // admin-ajax posts 404'd ("Unknown action") because AdminRouter
                    // dispatches before init() reaches the service boot further down.
                    '\Instinctor\Services\ImageLibraryService',
                    '\Instinctor\Services\ImageOptimizerService',
                    
                    // Instagent Help — same pre-boot reason. Registers
                    // `instinctor_help_chunk` and `instinctor_help_ask`. Without this
                    // line every browse-item click in the help panel returned 404 with
                    // a "couldn't load this article" toast in the editor.
                    '\Instinctor\Services\InstagentHelpService',
                    '\Instinctor\Frontend\FrontendController',
                ] as $_svcClass) {
                    try {
                        if (!class_exists($_svcClass)) continue;
                        if (method_exists($_svcClass, 'instance'))         { $_svcClass::instance(); continue; }
                        if (method_exists($_svcClass, 'getInstance'))      { $_svcClass::getInstance(); continue; }
                        new $_svcClass();
                    } catch (\Throwable $e) {
                        // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
                        error_log('Instinctor admin-ajax service boot (' . $_svcClass . '): ' . $e->getMessage());
                        // phpcs:enable WordPress.PHP.DevelopmentFunctions
                    }
                }
            }
        }
        \Instinctor\Core\Admin\AdminRouter::init();

        

        // T10b — Frontend password reset routes (standalone: /forgot-password, /reset-password)
        //         WordPress: AJAX handlers registered below via FrontendController
        if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
            $pr = '\\Instinctor\\Frontend\\PasswordResetController';
            \Instinctor\Core\Router::add('forgot-password', '/forgot-password',
                function(array $p) use ($pr) {
                    strtoupper(sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'] ?? 'GET'))) === 'POST'
                        ? $pr::handleForgot($p)
                        : $pr::showForgot($p);
                }
            );
            \Instinctor\Core\Router::add('reset-password', '/reset-password',
                function(array $p) use ($pr) {
                    strtoupper(sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'] ?? 'GET'))) === 'POST'
                        ? $pr::handleReset($p)
                        : $pr::showReset($p);
                }
            );

            // Google OAuth handoff removed 2026-07-05 (SocialLoginService gone).

            // Estimate + invoice public pages (tokenized, no login) are VERTICAL product
            // plumbing: only vertical sites (instinctor.com + provisioned vertical
            // standalones) send them. Bespoke client sites (aokarate.com etc.) must not
            // carry irrelevant functionality (Sergei 2026-07-20), so both routes are gated
            // on isVerticalSite() rather than registered for every install.
            if (self::isVerticalSite()) {
                // Home Services invoicing — own-branded public pay page for an invoice.
                $invCtrl = '\\Instinctor\\Frontend\\InvoiceController';
                \Instinctor\Core\Router::add('invoice-pay', '/invoice/:token',
                    function(array $pp) use ($invCtrl) {
                        strtoupper(sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'] ?? 'GET'))) === 'POST'
                            ? $invCtrl::handlePay($pp)
                            : $invCtrl::showInvoice($pp);
                    }
                );

                
            }

            // Legal pages (/terms, /privacy) are now editable Impulse
            // pages served via the catch-all /:slug route below. The
            // Impulse versions include the Google OAuth disclosure that
            // OAuth verification requires.

            // Platform-only marketing routes. The designer marketplace was
            // fully removed (2.1.413+, completed 2026-06-22): gig commerce,
            // the lean designer portfolio at /d/:slug, the brief funnel, and
            // the 2-segment /:designer/:page URLs are all gone. /designer/:slug
            // (below) survives only as the namespace for owner_id-owned
            // pages, e.g. the demo sites.
            //
            // Must come before /:slug catch-all so the named routes win.
            if (self::isInstinctorApp()) { // platform-only marketing routes (A)
            // Direct-lead solo signup landing — owned by StartController, not
            // GigPageController (extracted 2.0.778: /start is platform onboarding,
            // not marketplace). Both share MarketingShell for the page chrome.
            $startCtrl = '\\Instinctor\\Frontend\\StartController';
            \Instinctor\Core\Router::add('start',        '/start',                [$startCtrl, 'start']);
            \Instinctor\Core\Router::add('pricing',       '/pricing',              function() { header('Location: /start', true, 301); exit; });
            // /apply — retired free-managed-build program (page 144, now unpublished). The
            // instant self-serve builder covers free-site acquisition at scale and does not
            // undercut the managed tier, so any stray /apply link funnels there. 2026-07-22.
            \Instinctor\Core\Router::add('apply-legacy',  '/apply',                function() { header('Location: /make-a-site', true, 301); exit; });
            // /talk-to-us — retired 2026-08-11 (page 213 unpublished). It carried a contact
            // form that received ZERO submissions in its entire life, while chat handled 80
            // conversations over the same period. Chat is now the contact channel on both
            // sites and hands off to a person, so the form was a dead end wearing the label
            // people click when they need help.
            \Instinctor\Core\Router::add('talktous-legacy', '/talk-to-us',        function() { header('Location: https://academy.instinctor.com/free-website/#apply', true, 301); exit; });
            // /get-started — retired 2026-08-11. It was the pay-first managed signup, but
            // it was never wired to the homepage, drew 6 inbound links estate-wide, and
            // still pointed onward at the retired /start. The managed option is now
            // explained by a person once an application arrives, so the one application
            // form is the only entry point and stray links funnel there. Same shape as
            // /apply above.
            \Instinctor\Core\Router::add('getstarted',   '/get-started',          function() { header('Location: https://academy.instinctor.com/free-website/#apply', true, 301); exit; });
            // Passwordless sign-in: consumes a signed SSO/magic-link token and logs the
            // user in (free-publish immediate hand-off, emailed magic-links, and the
            // managed provisioning hand-off all land here). Handler is static.
            \Instinctor\Core\Router::add('sso',          '/sso',                  ['\\Instinctor\\Frontend\\SSOController', 'sso']);
            // Tenant site owner's control panel (site_owner role lands here after
            // login, not the vendor admin). Scoped to their owner_id.
            \Instinctor\Core\Router::add('dashboard',    '/dashboard',            ['\\Instinctor\\Frontend\\TenantDashboardController', 'dashboard']);
            \Instinctor\Core\Router::add('dash-bookings','/dashboard/bookings',   ['\\Instinctor\\Frontend\\TenantDashboardController', 'bookings']);
            \Instinctor\Core\Router::add('dash-edit',    '/dashboard/edit',       ['\\Instinctor\\Frontend\\TenantDashboardController', 'edit']);
            // InCommand, the mobile panel: the dashboard in the pocket. Shell +
            // PWA plumbing (manifest and service worker must be same-scope, so
            // they are routes, not assets).
            \Instinctor\Core\Router::add('dash-app',      '/dashboard/app',           ['\\Instinctor\\Frontend\\TenantDashboardController', 'app']);
            \Instinctor\Core\Router::add('dash-app-mf',   '/dashboard/app/manifest',  ['\\Instinctor\\Frontend\\TenantDashboardController', 'appManifest']);
            \Instinctor\Core\Router::add('dash-app-sw',   '/dashboard/app/sw.js',     ['\\Instinctor\\Frontend\\TenantDashboardController', 'appSw']);
            \Instinctor\Core\Router::add('dash-app-req',  '/dashboard/app/requests',  ['\\Instinctor\\Frontend\\TenantDashboardController', 'appRequests']);
            \Instinctor\Core\Router::add('dash-app-chat', '/dashboard/app/chats',     ['\\Instinctor\\Frontend\\TenantDashboardController', 'appChats']);
            \Instinctor\Core\Router::add('dash-app-cam',  '/dashboard/app/photo',     ['\\Instinctor\\Frontend\\TenantDashboardController', 'appPhoto']);
            \Instinctor\Core\Router::add('dash-app-add',  '/dashboard/app/photo-add', ['\\Instinctor\\Frontend\\TenantDashboardController', 'appPhotoAdd']);
            \Instinctor\Core\Router::add('dash-app-q',    '/dashboard/app/quote',        ['\\Instinctor\\Frontend\\TenantDashboardController', 'appQuote']);
            \Instinctor\Core\Router::add('dash-app-qs',   '/dashboard/app/quote-status', ['\\Instinctor\\Frontend\\TenantDashboardController', 'appQuoteStatus']);
            \Instinctor\Core\Router::add('dash-app-b',    '/dashboard/app/booking',      ['\\Instinctor\\Frontend\\TenantDashboardController', 'appBooking']);
            \Instinctor\Core\Router::add('dash-app-ba',   '/dashboard/app/booking-act',  ['\\Instinctor\\Frontend\\TenantDashboardController', 'appBookingAct']);
            \Instinctor\Core\Router::add('dash-app-c',    '/dashboard/app/chat',        ['\\Instinctor\\Frontend\\TenantDashboardController', 'appChat']);
            \Instinctor\Core\Router::add('dash-app-cs',   '/dashboard/app/chat-send',   ['\\Instinctor\\Frontend\\TenantDashboardController', 'appChatSend']);
            \Instinctor\Core\Router::add('dash-app-cd',   '/dashboard/app/chat-draft',  ['\\Instinctor\\Frontend\\TenantDashboardController', 'appChatDraft']);
            \Instinctor\Core\Router::add('dash-app-more', '/dashboard/app/more',        ['\\Instinctor\\Frontend\\TenantDashboardController', 'appMore']);
            \Instinctor\Core\Router::add('dash-app-qr',   '/dashboard/app/qr',          ['\\Instinctor\\Frontend\\TenantDashboardController', 'appQr']);
            \Instinctor\Core\Router::add('dash-app-svc',  '/dashboard/app/services',    ['\\Instinctor\\Frontend\\TenantDashboardController', 'appServices']);
            \Instinctor\Core\Router::add('dash-app-svp',  '/dashboard/app/svc-price',   ['\\Instinctor\\Frontend\\TenantDashboardController', 'appSvcPrice']);
            \Instinctor\Core\Router::add('dash-app-cl',   '/dashboard/app/close',       ['\\Instinctor\\Frontend\\TenantDashboardController', 'appClose']);
            // The owner's sign-in link. Same handler on both environments; on
            // WordPress it is claimed in routePanel() before WP resolves the query.
            \Instinctor\Core\Router::add('dash-panel-login', '/dashboard/panel-login', ['\\Instinctor\\Frontend\\TenantDashboardController', 'panelLogin']);
            \Instinctor\Core\Router::add('dash-app-dom',  '/dashboard/app/domain',        ['\\Instinctor\\Frontend\\TenantDashboardController', 'appDomain']);
            \Instinctor\Core\Router::add('dash-app-doms', '/dashboard/app/domain-search', ['\\Instinctor\\Frontend\\TenantDashboardController', 'appDomainSearch']);
            \Instinctor\Core\Router::add('dash-app-domb', '/dashboard/app/domain-buy',    ['\\Instinctor\\Frontend\\TenantDashboardController', 'appDomainBuy']);
            \Instinctor\Core\Router::add('dash-clients', '/dashboard/clients',    ['\\Instinctor\\Frontend\\TenantDashboardController', 'clients']);
            \Instinctor\Core\Router::add('dash-team',    '/dashboard/team',       ['\\Instinctor\\Frontend\\TenantDashboardController', 'team']);
            \Instinctor\Core\Router::add('dash-svcs',    '/dashboard/services',   ['\\Instinctor\\Frontend\\TenantDashboardController', 'services']);
            // The leads the quote block brings in. Without this a tenant's estimates were
            // unreachable: /admin/estimates bounces site_owner and the vendor's list is
            // scoped owner_id IS NULL, so the rows were invisible to everyone.
            \Instinctor\Core\Router::add('dash-quotes',  '/dashboard/quotes',     ['\\Instinctor\\Frontend\\TenantDashboardController', 'quotes']);
            // Ask a customer for a Google review. For a trade this is THE Pro driver: they
            // are chosen on their star rating, and it is the only Pro feature they can see
            // working before they buy it (compose and preview free, sending is Pro).
            \Instinctor\Core\Router::add('dash-reviews', '/dashboard/reviews',    ['\\Instinctor\\Frontend\\TenantDashboardController', 'reviews']);
            // Their own job photos, grouped by service. Only meaningful for trades that
            // show work rather than sell time, but harmless elsewhere: the screen just
            // lists whatever services they have (2026-07-28).
            \Instinctor\Core\Router::add('dash-work', '/dashboard/work',    ['\\Instinctor\\Frontend\\TenantDashboardController', 'work']);
            \Instinctor\Core\Router::add('dash-settings','/dashboard/settings',   ['\\Instinctor\\Frontend\\TenantDashboardController', 'settings']);
            \Instinctor\Core\Router::add('dash-qr',     '/dashboard/qr',         ['\\Instinctor\\Frontend\\TenantDashboardController', 'qr']);
            \Instinctor\Core\Router::add('dash-assistant','/dashboard/assistant', ['\\Instinctor\\Frontend\\TenantDashboardController', 'assistant']);
            \Instinctor\Core\Router::add('dash-chat',     '/dashboard/chat',       ['\\Instinctor\\Frontend\\TenantDashboardController', 'chat']);
            \Instinctor\Core\Router::add('dash-chat-poll','/dashboard/chat/poll',  ['\\Instinctor\\Frontend\\TenantDashboardController', 'chatPoll']);
            \Instinctor\Core\Router::add('dash-help',    '/dashboard/help',       ['\\Instinctor\\Frontend\\TenantDashboardController', 'help']);
            \Instinctor\Core\Router::add('dash-pro',     '/dashboard/pro',        ['\\Instinctor\\Frontend\\TenantDashboardController', 'pro']);

            // /upgrade — public "buy Pro for your own WordPress" page. The free
            // WP plugin's Upgrade-to-Pro button + activate notice point here.
            // Distinct from /start (hosted standalone): this sells a Pro LICENSE
            // via the platform /api/checkout. See UpgradeController.
            $upgradeCtrl = '\\Instinctor\\Frontend\\UpgradeController';
            \Instinctor\Core\Router::add('upgrade',      '/upgrade',              [$upgradeCtrl, 'upgrade']);

            // /wordpress is served by the cinematic showcase page (slug
            // 'wordpress', page 124) via the /:slug catch-all below, not by a
            // code-built landing. That page is the designer-facing front door:
            // it links out to the wp.org plugin directory and to Features &
            // Pricing. The old WordPressController landing and the /download/wp
            // zip route were retired 2.1.2363 (the plugin ships from wp.org, so
            // there is no self-hosted zip). The ~50 marketing pages that link to
            // /wordpress now land on this showcase.
            // Affiliate program landing — public marketing page, anyone (not
            // just designers) can sign up to refer subscriptions. Pays $10/mo
            // recurring lifetime per active referral. Page is standalone-
            // rendered; the sign-up form posts to platform.instinctor.com,
            // which owns affiliate account creation, commission ledger, and
            // Stripe Connect payouts.
            $affCtrl = '\\Instinctor\\Frontend\\AffiliateController';
            \Instinctor\Core\Router::add('affiliate', '/affiliate',               [$affCtrl, 'index']);
            // Refund Policy - public legal page (standalone-only marketing).
            $refundCtrl = '\\Instinctor\\Frontend\\RefundPolicyController';
            \Instinctor\Core\Router::add('refund-policy', '/refund-policy',               [$refundCtrl, 'index']);
            // The Instinctor directory: the public registry of network
            // businesses (instinctor.com tenants + own-domain standalones).
            // Dark until instinctor_directory_public=1; DirectoryController
            // 404s + noindexes everything for the public until then. The
            // profile route registers before :trade so 'b' can never be
            // parsed as a trade slug.
            $dirCtrl = '\\Instinctor\\Frontend\\DirectoryController';
            \Instinctor\Core\Router::add('directory',          '/directory',              [$dirCtrl, 'hub']);
            // Manage routes MUST precede the :trade/:city catch-alls, or
            // /directory/manage/:token would be read as a category page.
            // No public message route: messaging is member-to-member from
            // the portal (the old /b/:slug/message form was a spam surface).
            \Instinctor\Core\Router::add('directory-view',     '/directory/b/:slug/view',   [$dirCtrl, 'view']);
            \Instinctor\Core\Router::add('directory-manage-save', '/directory/manage/:token/save', [$dirCtrl, 'manageSave']);
            \Instinctor\Core\Router::add('directory-manage-message', '/directory/manage/:token/message', [$dirCtrl, 'manageMessage']);
            \Instinctor\Core\Router::add('directory-manage',   '/directory/manage/:token', [$dirCtrl, 'manage']);
            \Instinctor\Core\Router::add('directory-profile',  '/directory/b/:slug',      [$dirCtrl, 'profile']);
            \Instinctor\Core\Router::add('directory-trade',    '/directory/:trade',       [$dirCtrl, 'trade']);
            \Instinctor\Core\Router::add('directory-category', '/directory/:trade/:city', [$dirCtrl, 'category']);
            // Referral link - the blogger share URL instinctor.com/ref/CODE. Sets
            // the 90-day attribution cookie (first-touch wins) on .instinctor.com
            // and bounces home. The code is validated at conversion time
            // (recordRecurringConversion requires status=active), so no lookup here.
            \Instinctor\Core\Router::add('ref', '/ref/:code', function (array $p) {
                $code = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string) ($p['code'] ?? '')));
                if ($code !== '') {
                    // Stable per-browser id so clicks dedupe per visitor per day.
                    $vid = preg_replace('/[^a-f0-9]/', '', sanitize_text_field(wp_unslash($_COOKIE['instinctor_vid'] ?? '')));
                    if ($vid === '' && function_exists('random_bytes')) {
                        $vid = bin2hex(random_bytes(16));
                        setcookie('instinctor_vid', $vid, [
                            'expires'  => time() + 365 * 24 * 3600,
                            'path'     => '/',
                            'domain'   => '.instinctor.com',
                            'secure'   => true,
                            'httponly' => true,
                            'samesite' => 'Lax',
                        ]);
                    }
                    // Count the click on the platform (deduped there per visitor/IP
                    // per day) and learn whether this is a live referral code, so
                    // we never store a bogus/typo'd code as the first-touch cookie.
                    // The standalone HTTP shim is synchronous regardless of the
                    // 'blocking' flag, so reading the reply costs nothing extra.
                    $codeIsActive = false;
                    if (function_exists('wp_remote_post')) {
                        $resp = wp_remote_post('https://platform.instinctor.com/api/referral/click', [
                            'timeout' => 2,
                            'body'    => [
                                'code' => $code,
                                'vid'  => $vid,
                                'ip'   => sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')),
                            ],
                        ]);
                        if (!is_wp_error($resp)) {
                            $data = json_decode(wp_remote_retrieve_body($resp), true);
                            $codeIsActive = !empty($data['active']);
                        }
                    }
                    // First-touch attribution: only ever store a VALID code, and
                    // only when none is set yet. Storing valid codes exclusively
                    // means the first real referrer wins AND a bogus first hit can
                    // no longer block a later genuine one (it was never stored).
                    if ($codeIsActive && empty($_COOKIE['instinctor_ref'])) {
                        setcookie('instinctor_ref', $code, [
                            'expires'  => time() + 90 * 24 * 3600,
                            'path'     => '/',
                            'domain'   => '.instinctor.com',
                            'secure'   => true,
                            'httponly' => true,
                            'samesite' => 'Lax',
                        ]);
                    }
                }
                header('Location: /');
                exit;
            });
            } // end platform-only routes (A)

            // /projects (job board) + /showcase (gallery) removed 2.1.413 —
            // marketplace teardown. Both pulled from gig data which no longer
            // exists as a customer-facing surface.

            // SEO endpoints — Standalone doesn't run WP's `init` action, so
            // FrontendController::handleSEOURLs never fires. Wire them directly.
            // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- these routes emit COMPLETE XML/text documents (Content-Type: application/xml / text/plain), not HTML fragments; no esc_*() HTML-escaper applies to a whole document. URLs inside are esc_url()'d at construction within SEOService.
            \Instinctor\Core\Router::add('sitemap-index',    '/sitemap.xml',          function() {
                header('Content-Type: application/xml; charset=utf-8');
                echo \Instinctor\Services\SEOService::generateSitemapIndex();
                exit;
            });
            \Instinctor\Core\Router::add('sitemap-pages',    '/sitemap-pages.xml',    function() {
                header('Content-Type: application/xml; charset=utf-8');
                echo \Instinctor\Services\SEOService::generatePagesSitemap();
                exit;
            });
            \Instinctor\Core\Router::add('sitemap-products', '/sitemap-products.xml', function() {
                header('Content-Type: application/xml; charset=utf-8');
                echo \Instinctor\Services\SEOService::generateProductsSitemap();
                exit;
            });
            \Instinctor\Core\Router::add('sitemap-docs', '/sitemap-docs.xml', function() {
                header('Content-Type: application/xml; charset=utf-8');
                echo \Instinctor\Services\SEOService::generateDocsSitemap();
                exit;
            });
            \Instinctor\Core\Router::add('sitemap-features', '/sitemap-features.xml', function() {
                header('Content-Type: application/xml; charset=utf-8');
                echo \Instinctor\Services\SEOService::generateFeaturesSitemap();
                exit;
            });
            \Instinctor\Core\Router::add('sitemap-directory', '/sitemap-directory.xml', function() {
                header('Content-Type: application/xml; charset=utf-8');
                echo \Instinctor\Services\SEOService::generateDirectorySitemap();
                exit;
            });
            \Instinctor\Core\Router::add('robots-txt', '/robots.txt', function() {
                header('Content-Type: text/plain; charset=utf-8');
                echo \Instinctor\Services\SEOService::generateRobotsTxt();
                exit;
            });
            // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped

            // /favicon.ico — browsers auto-request this regardless of <link rel="icon">
            // in the HTML. When the site owner uploaded a custom favicon in
            // Settings > Store info > Logo & Favicon, redirect /favicon.ico to
            // that URL so the browser fetches it for the legacy auto-request
            // path too. Otherwise serve the bundled Instinctor SVG so the
            // request doesn't 404 in console.
            \Instinctor\Core\Router::add('favicon', '/favicon.ico', function() {
                $favUrl = (string) \Instinctor\Core\Config::get('instinctor_favicon_url', '');
                if ($favUrl !== '') {
                    header('Cache-Control: no-cache');
                    header('Location: ' . $favUrl, true, 302);
                    exit;
                }
                header('Content-Type: image/svg+xml; charset=utf-8');
                header('Cache-Control: public, max-age=604800, immutable');
                // Favicon: 32×32 rounded square in Instinctor dark, white
                // mark on top. Mark is the new fill-based logo (CorelDRAW
                // C-with-square), scaled to fit a 24×24 inner box and
                // offset by 4px on each side. Inline rather than referenced
                // so the favicon endpoint is self-contained.
                echo '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
                   . '<rect width="32" height="32" rx="6" fill="#1d2327"/>'
                   . '<g transform="translate(4 4) scale(2.288)" fill="#ffffff" fill-rule="evenodd">'
                   .   '<path d="M3.02 0.02l2.23 0c1.44,0 2.75,0.59 3.7,1.54 0.95,0.95 1.54,2.26 1.54,3.7 0,1.44 -0.59,2.75 -1.54,3.7 -0.95,0.95 -2.26,1.54 -3.7,1.54 -1.44,0 -2.75,-0.59 -3.7,-1.54 -0.95,-0.95 -1.54,-2.26 -1.54,-3.7l0 -2.23 2.31 0 0 2.23c0,0.8 0.33,1.53 0.86,2.06 0.53,0.53 1.26,0.86 2.06,0.86 0.8,0 1.53,-0.33 2.06,-0.86 0.53,-0.53 0.86,-1.26 0.86,-2.06 0,-0.8 -0.33,-1.53 -0.86,-2.06 -0.53,-0.53 -1.26,-0.86 -2.06,-0.86l-2.23 0 0 -2.31z"/>'
                   .   '<rect x="0.02" y="0.02" width="2.31" height="2.31"/>'
                   . '</g>'
                   . '</svg>';
                exit;
            });

            // Referral program — public pages on instinctor.com.
            if (self::isInstinctorApp()) { // platform-only (B)
            $partnersCtrl = '\\Instinctor\\Frontend\\PartnersPageController';
            \Instinctor\Core\Router::add('partners-dash', '/partners/dashboard',  [$partnersCtrl, 'dashboard']);
            \Instinctor\Core\Router::add('partners',      '/partners',            [$partnersCtrl, 'apply']);
            \Instinctor\Core\Router::add('about',         '/about',               ['\\Instinctor\\Frontend\\AboutController', 'show']);
            } // end platform-only routes (B)

            // Page template path captured for the closures below.
            $pageTpl = INSTINCTOR_PLUGIN_DIR . 'templates/impulse-page.php';

            // /docs, /faq, /features/*, /tutorials/* — instinctor.com marketing
            // + documentation pages. Gated to the instinctor.com app: these
            // controllers are not used on a WordPress install (WP serves its own
            // routes), and gating lets the WordPress.org build EXCLUDE the four
            // controller files entirely (build.py WP_EXCLUDE_FILES) — they
            // template with short-echo/$e() output that Plugin Check flags and
            // that has no purpose on a WP site. Must come before the /:slug
            // catch-all on instinctor.com.
            if (self::isInstinctorApp()) {
            $docsCtrl = new \Instinctor\Frontend\DocsController();
            \Instinctor\Core\Router::add('docs',         '/docs',           [$docsCtrl, 'index']);
            \Instinctor\Core\Router::add('docs-article', '/docs/:cat/:slug', [$docsCtrl, 'article']);
            $faqCtrl = new \Instinctor\Frontend\FaqController();
            \Instinctor\Core\Router::add('faq', '/faq', [$faqCtrl, 'index']);
            $featCtrl = new \Instinctor\Frontend\FeaturesController();
            \Instinctor\Core\Router::add('feat-builder',  '/features/visual-builder', [$featCtrl, 'visualBuilder']);
            \Instinctor\Core\Router::add('feat-ecommerce','/features/ecommerce',      [$featCtrl, 'ecommerce']);
            \Instinctor\Core\Router::add('feat-chat',     '/features/smart-chat',     [$featCtrl, 'smartChat']);
            \Instinctor\Core\Router::add('feat-seo',      '/features/seo-tools',      [$featCtrl, 'seoTools']);
            \Instinctor\Core\Router::add('feat-booking',  '/features/booking',        [$featCtrl, 'booking']);
            \Instinctor\Core\Router::add('feat-app',      '/features/mobile-app',     [$featCtrl, 'mobileApp']);
            \Instinctor\Core\Router::add('feat-instagent','/features/instagent',      [$featCtrl, 'instagent']);
            \Instinctor\Core\Router::add('feat-pizza',    '/features/pizza-builder',  [$featCtrl, 'pizzaBuilder']);
            $tutCtrl = new \Instinctor\Frontend\TutorialsController();
            \Instinctor\Core\Router::add('tutorials',           '/tutorials',                         [$tutCtrl, 'index']);
            \Instinctor\Core\Router::add('tut-restaurant',      '/tutorials/restaurant-website',      [$tutCtrl, 'restaurantWebsite']);
            \Instinctor\Core\Router::add('tut-portfolio',       '/tutorials/portfolio-website',       [$tutCtrl, 'portfolioWebsite']);
            \Instinctor\Core\Router::add('tut-booking',         '/tutorials/online-booking',          [$tutCtrl, 'onlineBooking']);
            \Instinctor\Core\Router::add('tut-ecommerce',       '/tutorials/ecommerce-store',         [$tutCtrl, 'ecommerceStore']);
            \Instinctor\Core\Router::add('tut-chat',            '/tutorials/add-chat',                [$tutCtrl, 'addChat']);
            } // end instinctor.com marketing/docs routes

            // Personalized instant demo (instinctor.com only). /instudio = the
            // business-name entry screen; /demo/:token = anonymous render of a
            // signed DRAFT demo page. The demo route sets INSTINCTOR_RENDER_PAGE_ID
            // to bypass the /:slug published+owner gate below, so demo pages stay
            // drafts (out of the public slug namespace + sitemap) yet still render
            // for the anonymous visitor who created them.
            if (self::isInstinctorApp()) {
                // One entry screen per vertical, registered FROM the registry: adding a
                // vertical means adding a Core\Verticals entry, not a route here. Every
                // route serves the same template; only $instinctor_demo_vertical differs,
                // which selects the copy, the template page and the image pool.
                $demoEntryTpl = INSTINCTOR_PLUGIN_DIR . 'templates/demo-entry.php';
                foreach (\Instinctor\Core\Verticals::routes() as $path => $vertical) {
                    \Instinctor\Core\Router::add(
                        'demo-entry-' . $vertical,
                        $path,
                        static function (array $p) use ($demoEntryTpl, $vertical) {
                            $GLOBALS['instinctor_demo_vertical'] = $vertical;
                            if (file_exists($demoEntryTpl)) include $demoEntryTpl;
                        }
                    );
                }
                // Orientation screen for paid ads. A per-ad channel (Craigslist) cannot
                // afford one link per vertical, so the ad carries /free and the visitor
                // picks their trade here. Cards come from the registry, so this route
                // covers every vertical that has a 'chooser' block.
                $freeEntryTpl = INSTINCTOR_PLUGIN_DIR . 'templates/free-entry.php';
                \Instinctor\Core\Router::add('free-entry', '/free', static function (array $p) use ($freeEntryTpl) {
                    if (file_exists($freeEntryTpl)) include $freeEntryTpl;
                });

                // Passwordless owner login: enter email -> magic link back to /dashboard.
                $loginTpl = INSTINCTOR_PLUGIN_DIR . 'templates/login.php';
                \Instinctor\Core\Router::add('owner-login', '/login', function(array $p) use ($loginTpl) {
                    if (file_exists($loginTpl)) include $loginTpl;
                });
                // Legacy alias: the entry screen used to live at /try. Keep it as a
                // permanent redirect so old links and bookmarks land on /instudio.
                \Instinctor\Core\Router::add('demo-entry-legacy', '/try', function() { header('Location: /instudio', true, 301); exit; });
                \Instinctor\Core\Router::add('demo-view', '/demo/:token', function(array $p) use ($pageTpl) {
                    $pid = \Instinctor\Services\DemoService::resolveToken((string) ($p['token'] ?? ''));
                    if ($pid <= 0) { \Instinctor\Frontend\MarketingShell::renderNotFound('/demo'); return; }
                    if (!defined('INSTINCTOR_RENDER_PAGE_ID')) define('INSTINCTOR_RENDER_PAGE_ID', $pid);
                    $GLOBALS['impulse_is_demo'] = true;
                    // Render the page, then inject the demo overlay (palette picker
                    // etc.) before </body> so it lives inside the document.
                    ob_start();
                    if (file_exists($pageTpl)) include $pageTpl;
                    $__demoHtml = ob_get_clean();
                    $__demoOv   = '';
                    $__demoOvFile = INSTINCTOR_PLUGIN_DIR . 'templates/demo-overlay.php';
                    if (file_exists($__demoOvFile)) { ob_start(); include $__demoOvFile; $__demoOv = ob_get_clean(); }
                    if ($__demoOv !== '') {
                        // Inject before the LAST </body> (the real document close). A plain
                        // str_replace also hits </body> string-literals inside client scripts,
                        // trapping the overlay (which has its own <script>) inside them.
                        $__p = strrpos($__demoHtml, '</body>');
                        $__demoHtml = ($__p !== false)
                            ? substr($__demoHtml, 0, $__p) . $__demoOv . "\n" . substr($__demoHtml, $__p)
                            : $__demoHtml . $__demoOv;
                    }
                    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $__demoHtml is a fully rendered page document (page template output + demo overlay markup) emitted only on the app-only /demo/:token route (isInstinctorApp gate); escaping a whole HTML document would corrupt it.
                    echo $__demoHtml;
                });
            }

            // The /designer/:slug namespace was removed 2026-06-22 — that URL
            // pattern is never used. Former designer-owned demo pages now live
            // at top-level slugs (owner_id set to NULL) and are served by
            // the /:slug catch-all below.

            // Catch-all frontend page renderer — must be last so named routes above win.
            // Serves Impulse pages by slug: / → front page, /:slug → page by slug.
            // impulse-page.php resolves slug from REQUEST_URI itself.
            \Instinctor\Core\Router::add('home', '/', function(array $p) use ($pageTpl) {
                if (file_exists($pageTpl)) include $pageTpl;
            });
            \Instinctor\Core\Router::add('page', '/:slug', function(array $p) use ($pageTpl) {
                // Top-level namespace — only marketing pages (owner_id
                // NULL) resolve here. Designer-owned pages live at
                // /designer/:slug. getByPublicSlug enforces this exclusion
                // at the DB level. Without it, designer slugs (`profile`,
                // `starter`, `growth`, etc.) would leak into the global
                // URL space and shadow real marketing routes.
                $slug = (string) ($p['slug'] ?? '');
                $page = \Instinctor\Services\PageService::getRoutableBySlug($slug);
                if (!$page || ($page['status'] ?? '') !== 'published') {
                    \Instinctor\Frontend\MarketingShell::renderNotFound('/' . $slug);
                    return;
                }
                // Pro subdomain canonical: a Pro owner's page lives at
                // <slug>.instinctor.com. On the MAIN domain, 301 the path form to
                // the subdomain. Host-guarded so the subdomain's own rewrite
                // (HostRouter) never loops back here. GET only (a redirect drops a
                // POST body). 2026-07-22.
                $_poOwner = (int) ($page['owner_id'] ?? 0);
                $_poHost  = strtolower((string) preg_replace('/:\d+$/', '', (string) ($_SERVER['HTTP_HOST'] ?? '')));
                if ($_poOwner > 0
                    && ($_poHost === 'instinctor.com' || $_poHost === 'www.instinctor.com')
                    && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET'
                    && class_exists('\\Instinctor\\Core\\TenantPlan')
                    && \Instinctor\Core\TenantPlan::isPro($_poOwner)) {
                    $_poQs = (string) wp_parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_QUERY);
                    wp_safe_redirect('https://' . $slug . '.instinctor.com/' . ($_poQs !== '' ? '?' . $_poQs : ''), 301);
                    exit;
                }
                // Canonical trailing slash. Every page answers on BOTH /slug and
                // /slug/ because the router rtrims the path before matching
                // (StandaloneDriver ~L94), while the canonical tag and the sitemap
                // both emit the slashed form. So Google crawls an unslashed internal
                // link, finds a canonical pointing elsewhere, and files the URL under
                // "Alternate page with proper canonical tag" — 59 of them. Nothing is
                // lost (it indexes the canonical), but it burns crawl budget on a
                // duplicate of every page. One 301 collapses the pair.
                //
                // Placed HERE, after the slug is confirmed to be a real published
                // page, and NOT on the route match: /:slug matches anything, so
                // redirecting on the match alone would turn every clean 404 into a
                // 301-then-404. GET only — a redirect drops a POST body. Query string
                // is carried over. No loop risk: the target ends in '/', which fails
                // this same test on the next pass. Sergei 2026-07-16.
                $_reqPath = (string) wp_parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
                $_method  = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
                if ($_method === 'GET' && $_reqPath !== '' && substr($_reqPath, -1) !== '/') {
                    $_qs = (string) wp_parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_QUERY);
                    wp_safe_redirect($_reqPath . '/' . ($_qs !== '' ? '?' . $_qs : ''), 301);
                    exit;
                }
                // Marketing footer — only on non-scene, top-level pages that
                // don't already author their own footer block. Scene-mode
                // pages stay clean (cinematic). Already-authored footers win.
                $settings = \Instinctor\Services\PageService::getSettings((int)$page['id']);
                $isScene  = true; // every page is a screen sequence
                if (!$isScene) {
                    $els = \Instinctor\Services\PageService::getElements((int)$page['id']);
                    $hasFooter = false;
                    foreach ($els as $el) {
                        $t = $el['type'] ?? ($el['element_type'] ?? '');
                        if ($t === 'footer') { $hasFooter = true; break; }
                    }
                    // Marketing-shell fallback footer — used when the page
                    // doesn't author its own footer block. (The legacy
                    // auto-injected global_footer page was retired in the
                    // page/scene merge; footers are page content now.)
                    if (!$hasFooter && !defined('INSTINCTOR_MARKETING_FOOTER_HTML') && self::isInstinctorApp()) {
                        define('INSTINCTOR_MARKETING_FOOTER_HTML',
                            \Instinctor\Frontend\MarketingShell::footer('/' . $slug));
                    }
                }
                if (file_exists($pageTpl)) include $pageTpl;
            });
        }

        // T13 — Auto-update system.
        //   - Standalone: Services\AutoUpdater (signed manifest + atomic swap),
        //     wired from AdminRouter on /admin GET. Self-gates inside init().
        //   - WP Pro: Core\AutoUpdater registers WP's update-transient filters
        //     so the standard "Update available" notice + Plugin_Upgrader handle
        //     the install. Class is stripped from the wp.org zip — wp.org's
        //     daily transient handles the free channel.
        // Refactored in v2.1.1354 — single shared `/api/plugin/manifest`
        // endpoint feeds both paths.
        if (class_exists('\\Instinctor\\Core\\AutoUpdater')) {
            \Instinctor\Core\AutoUpdater::init();
        }

        // P2 — Platform service: daily ping, announcement notices, support tickets
        \Instinctor\Core\PlatformService::init();

        // Instagent Help — registers `instinctor_help_ask` and
        // `instinctor_help_chunk` AJAX handlers that bridge to
        // platform.instinctor.com/api/relay/ai-help. Constructor wires
        // both handlers via add_action / AjaxDispatcher::register.
        \Instinctor\Services\InstagentHelpService::instance();

        // T7 — Rate limit all instinctor REST API calls (public only — skip logged-in admin users)
        add_filter('rest_pre_dispatch', static function ($result, $server, $request) {
            $route = $request->get_route();
            if (strpos($route, '/instinctor/') === false) {
                return $result;
            }
            // Skip rate limiting for logged-in users with manage_options (builders, admins)
            if (function_exists('current_user_can') && current_user_can('manage_options')) {
                return $result;
            }
            $ip = \Instinctor\Core\Security::clientIp();
            if (!\Instinctor\Core\Security::rateLimit('rest_api', $ip)) {
                return new \WP_Error(
                    'rate_limited',
                    __('Too many requests. Please slow down.', 'instinctor'),
                    ['status' => 429]
                );
            }
            return $result;
        }, 10, 3);

        // Initialize cart session
        new \Instinctor\Services\CartService();

        // =====================================================================
        // Extension Services
        // All extensions boot unconditionally. Global gate system coming later.
        // Static-only services (Bundles, Addons, etc.) need no boot call
        // — they are called directly wherever needed. Only services with hooks,
        // cron, REST, or filter registration require an instance boot here.
        // =====================================================================

        // Subscriptions — Stripe webhook + cron renewal processing
        try {
            \Instinctor\Services\SubscriptionService::instance();
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('Instinctor SubscriptionService init error: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }

        // Bookings — REST availability/slot endpoints
        try {
            \Instinctor\Services\BookingService::instance();
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('Instinctor BookingService init error: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }

        // =====================================================================
        // Core Services
        // =====================================================================

        // Digital Downloads
        try {
            \Instinctor\Services\DigitalDownloadService::instance();
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('Instinctor DigitalDownloadService init error: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }

        // AI Content Generation
        try {
            \Instinctor\Services\AIContentService::getInstance();
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('Instinctor AIContentService init error: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }

        // Chat
        try {
            \Instinctor\Services\ChatService::instance();
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('Instinctor ChatService init error: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }

        // Analytics
        try {
            \Instinctor\Services\AdvancedAnalyticsService::instance();
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('Instinctor AdvancedAnalyticsService init error: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }

        // Upsell (order bumps, cross-sells)
        try {
            \Instinctor\Services\UpsellService::instance();
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('Instinctor UpsellService init error: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }

        // Image library — unified service for both WordPress and standalone.
        // All Instinctor images are stored in instinctor_images (not WP attachments).
        try {
            \Instinctor\Services\ImageLibraryService::instance();
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('Instinctor ImageLibraryService init error: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }

        

        // Software Licensing (internal — for stores selling software)
        try {
            \Instinctor\Services\SoftwareLicensingService::instance();
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('Instinctor SoftwareLicensingService init error: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }

        // AI Domain Finder — post-payment NameSilo registration listener.
        // Inert until instinctor_namesilo.api_key is configured (fulfillOrder
        // guards on isEnabled), so this attaches a no-op hook otherwise.
        try {
            \Instinctor\Services\DomainService::boot();
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('Instinctor DomainService init error: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }
        
        // v2.9.122 Set default store type for existing installations
        if (!Config::get('instinctor_store_type')) {
            Config::set('instinctor_store_type', 'mixed');
        }
        
        // Hook password reset to use EmailService (filter the email to replace it)
        add_filter('retrieve_password_notification_email', [$this, 'filterPasswordResetEmail'], 10, 4);

        if (is_admin()) {
            new AdminController();
            
            // Usage analytics - anonymous stats for marketing (opt-in)
            new UsageAnalytics();

            // Auto-sync WP users into instinctor_users on first admin load.
            // Runs once, silently — ensures the table is populated before
            // anyone manually triggers a WP→Standalone migration.
            add_action('admin_init', [$this, 'maybeSyncUsersOnce']);
        }

        // Previously gated behind `!is_admin() || wp_doing_ajax()`, which meant
        // the Impulse editor preview (admin context) never registered commerce
        // shortcodes (account/checkout/orders/cart etc.) — they rendered as
        // literal text in the editor. Instantiate unconditionally; the
        // constructor's hook targets are still URL-conditional where needed.
        new FrontendController();

        // REST API always loads
        new RestController();

        $this->initialized = true;
    }

    public function addCronSchedules(array $schedules): array
    {
        $schedules['fifteen_minutes'] = [
            'interval' => 900,
            'display' => __('Every 15 Minutes', 'instinctor'),
        ];
        return $schedules;
    }

    /**
     * Auto-populate instinctor_users from WP users on first admin load.
     *
     * Only runs when:
     *   - INSTINCTOR_ENV is 'wordpress' (default)
     *   - instinctor_users table has no admin-role rows
     *   - The option 'instinctor_users_synced' is not set
     *
     * Marks completion with a WP option so it never runs again automatically.
     * The manual "Sync Users" button in Settings > System remains available
     * for re-syncing after new WP users are added.
     */
    public function maybeSyncUsersOnce(): void
    {
        // Already done — bail immediately (cheap option check)
        if (\get_option('instinctor_users_synced')) {
            return;
        }

        try {
            $table  = \Instinctor\Core\DB::prefix() . 'instinctor_users';
            $exists = \Instinctor\Core\DB::get_var("SELECT COUNT(*) FROM `{$table}`");

            // Only auto-sync when the table is empty to avoid overwriting
            // manual changes made since the last sync
            if ((int) $exists === 0) {
                $synced = \Instinctor\Core\UserService::syncFromWordPress();
                // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
                error_log("[Instinctor] Auto-synced {$synced} WP users into instinctor_users.");
                // phpcs:enable WordPress.PHP.DevelopmentFunctions
            }

            // Mark as done regardless — if the table had rows, assume it's
            // already populated from a prior manual sync or previous run
            \update_option('instinctor_users_synced', '1', false);

        } catch (\Throwable $e) {
            // Non-fatal — table may not exist yet on fresh install before migrations run
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('[Instinctor] maybeSyncUsersOnce: ' . $e->getMessage());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }
    }

    public function checkLowStock(): void
    {
        $threshold = Config::get('instinctor_low_stock_threshold', 5);
        $adminEmail = Config::get('admin_email', '');
        
        $lowStockProducts = DB::get_results(DB::prepare(
            "SELECT id, name, sku, stock_quantity FROM " . DB::prefix() . "instinctor_products 
             WHERE manage_stock = 1 AND stock_quantity <= %d AND stock_quantity > 0 AND status = 'published'",
            $threshold
        ));
        
        if (empty($lowStockProducts)) {
            return;
        }
        
        // Check if we've already notified today
        $lastNotified = Config::get('instinctor_low_stock_last_notified', '');
        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
        $today = date('Y-m-d');
        // phpcs:enable WordPress.DateTime.RestrictedFunctions
        
        if ($lastNotified === $today) {
            return;
        }
        
        // Send notification
        $emailService = \Instinctor\Services\EmailService::class;
        
        foreach ($lowStockProducts as $product) {
            $emailService::send('low_stock', $adminEmail, [
                'product_name' => $product->name,
                'stock_quantity' => $product->stock_quantity,
                'sku' => $product->sku ?: 'N/A',
                'store_name' => get_bloginfo('name'),
            ]);
        }
        
        Config::set('instinctor_low_stock_last_notified', $today);
    }

    /**
     * Handle plugin upgrades
     * This runs on every load to detect and handle version changes
     */
    private function handleUpgrade(): void
    {
        // Always run migrations — they are idempotent (skip already-applied ones).
        // This ensures all schema tables exist even when the plugin is reinstalled
        // at the same version or when a previous run was interrupted.
        Migration::run();

        $storedVersion  = Config::get('instinctor_db_version', '');
        $currentVersion = defined('INSTINCTOR_VERSION') ? INSTINCTOR_VERSION : '';

        if ($storedVersion === $currentVersion) {
            return;
        }

        // Version changed: clear integrity flags so a fresh check is performed.
        Config::delete('instinctor_integrity_hash');
        Config::delete('instinctor_integrity_version');
        Config::delete('instinctor_tamper_detected');

        // v3.21: If upgrading an existing install that already has WP users,
        // skip the auto-sync on first admin_init (table wasn't empty before).
        // Only applies when upgrading from pre-3.21.
        if (!\get_option('instinctor_users_synced') && $storedVersion && version_compare($storedVersion, '3.21.0', '<')) {
            try {
                $table = DB::prefix() . 'instinctor_users';
                $count = (int) DB::get_var("SELECT COUNT(*) FROM `{$table}`");
                if ($count > 0) {
                    // Already populated — don't auto-sync over existing data
                    \update_option('instinctor_users_synced', '1', false);
                }
            } catch (\Throwable $e) {
                // Table may not exist yet — migrations will create it, auto-sync will run after
            }
        }

        Config::set('instinctor_db_version', $currentVersion);
    }

    /**
     * Run one-time migrations
     */
    private function runMigrations(): void
    {
        $migrationsRun = Config::get('instinctor_migrations_run', []);
        
        // Fix handoff message escaping (v1.5.9)
        if (!in_array('fix_handoff_message_escaping', $migrationsRun)) {
            $handoffMessage = Config::get('instinctor_chat_handoff_message', '');
            if (!empty($handoffMessage)) {
                // Fix double-escaped quotes and backslashes
                $fixed = stripslashes($handoffMessage);
                $fixed = str_replace("\\'", "'", $fixed);
                $fixed = str_replace('\\"', '"', $fixed);
                if ($fixed !== $handoffMessage) {
                    Config::set('instinctor_chat_handoff_message', $fixed);
                }
            }
            $migrationsRun[] = 'fix_handoff_message_escaping';
            Config::set('instinctor_migrations_run', $migrationsRun);
        }
    }
    
    /**
     * Filter password reset email to use EmailService template
     * 
     * @param array $defaults Default email components
     * @param string $key Password reset key
     * @param string $user_login User login name
     * @param \WP_User $user_data User object
     * @return array|false Return false to prevent WP from sending, or modified array
     */
    public function filterPasswordResetEmail(array $defaults, string $key, string $user_login, $user_data)
    {
        // Build reset link
        $resetLink = network_site_url("wp-login.php?action=rp&key=$key&login=" . rawurlencode($user_login), 'login');
        
        // Get customer name
        $customerName = trim($user_data->first_name . ' ' . $user_data->last_name);
        if (empty($customerName)) {
            $customerName = $user_data->display_name ?: $user_login;
        }
        
        // Send using EmailService template
        $sent = \Instinctor\Services\EmailService::send('customer_reset_password', $user_data->user_email, [
            'customer_name' => $customerName,
            'customer_first_name' => $user_data->first_name ?: $customerName,
            'customer_email' => $user_data->user_email,
            'reset_link' => $resetLink,
        ]);
        
        // If we sent our email, prevent WordPress from sending its default
        if ($sent) {
            return false;
        }
        
        // Fall back to WordPress default if our email failed
        return $defaults;
    }

    // Prevent cloning
    private function __clone() {}

    // Prevent unserialization
    public function __wakeup()
    {
        throw new \Exception('Cannot unserialize singleton');
    }
}
