<?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'
        );
    }

    /**
     * Get active addons (must be manually activated, even on instinctor.com)
     */
    public static function getActiveAddons(): array
    {
        return Config::get('instinctor_active_addons', []);
    }

    // ── 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;
    }

    /** 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();
    }

    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).

            // Home Services invoicing — own-branded public pay page for an
            // invoice (tokenized, no login). Registered for ALL sites (client
            // sites send invoices), so it sits outside the isInstinctorApp gate.
            $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; });
            // Done-for-you managed signup ($99/mo all-in). Where the "Get started"
            // buttons on the vertical product + trade pages point.
            \Instinctor\Core\Router::add('getstarted',   '/get-started',          ['\\Instinctor\\Frontend\\ManagedStartController', 'start']);
            // 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']);
            \Instinctor\Core\Router::add('dash-clients', '/dashboard/clients',    ['\\Instinctor\\Frontend\\TenantDashboardController', 'clients']);
            \Instinctor\Core\Router::add('dash-settings','/dashboard/settings',   ['\\Instinctor\\Frontend\\TenantDashboardController', 'settings']);
            \Instinctor\Core\Router::add('dash-help',    '/dashboard/help',       ['\\Instinctor\\Frontend\\TenantDashboardController', 'help']);

            // /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 plugin distribution — /wordpress is the landing page,
            // /download/wp is the no-gate zip download (302 to platform's
            // /releases/<latest>-wp.zip). Per 2026-05-15 product call,
            // downloads are zero-friction — no email gate. Pro is a paid
            // upgrade ($29.90/mo) activated by license key inside the plugin.
            $wpCtrl = '\\Instinctor\\Frontend\\WordPressController';
            // /wordpress landing route disabled 2.1.413 — WordPressController::landing
            // was removed in an earlier refactor but the route registration was
            // never cleaned up; php-fpm reload that flushed OPcache exposed the
            // dangling [class, 'landing'] callable (TypeError from Router::add's
            // callable typehint). /download/wp is the actual public download path
            // and still works. If a `/wordpress` landing page is needed later, add
            // a `landing()` method back to WordPressController and re-enable.
            // \Instinctor\Core\Router::add('wordpress',    '/wordpress',            [$wpCtrl, 'landing']);
            \Instinctor\Core\Router::add('download-wp',  '/download/wp',          [$wpCtrl, 'download']);
            // 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']);
            // 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). Non-blocking so the redirect stays instant.
                    if (function_exists('wp_remote_post')) {
                        wp_remote_post('https://platform.instinctor.com/api/referral/click', [
                            'blocking' => false,
                            'timeout'  => 1,
                            'body'     => [
                                'code' => $code,
                                'vid'  => $vid,
                                'ip'   => sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')),
                            ],
                        ]);
                    }
                    // First-touch attribution cookie (a later link doesn't overwrite).
                    if (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('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-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). /try = 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()) {
                $demoEntryTpl = INSTINCTOR_PLUGIN_DIR . 'templates/demo-entry.php';
                \Instinctor\Core\Router::add('demo-entry', '/try', function(array $p) use ($demoEntryTpl) {
                    if (file_exists($demoEntryTpl)) include $demoEntryTpl;
                });
                \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;
                }
                // 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
        }
        
        // 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');
    }
}
