<?php

declare(strict_types=1);

namespace PrettyLinks\Pro;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
// Custom plugin tables (prli_*): table names interpolated from $wpdb->prefix (trusted),
// user values bind through $wpdb->prepare(). No caching: these tables are the source
// of truth for click/redirect data and must read-through. "meta_key"/"meta_value" here
// refer to our own prli_link_metas table, not wp_postmeta.
use PrettyLinks\GroundLevel\Container\Container;
use PrettyLinks\I18n\ScriptTranslations;
use PrettyLinks\Pro\Options\Store as OptionsStore;
use PrettyLinks\Pro\Admin\Assets as ProAdminAssets;
use PrettyLinks\Pro\Admin\Pages\Categories as CategoriesPage;
use PrettyLinks\Pro\Admin\Pages\CustomReports as CustomReportsPage;
use PrettyLinks\Pro\Admin\Pages\SplitTestReport as SplitTestReportPage;
use PrettyLinks\Pro\Admin\Pages\Tags as TagsPage;
use PrettyLinks\Pro\AutoCreate\AutoCreator;
use PrettyLinks\Pro\Database\Migrator as ProMigrator;
use PrettyLinks\Pro\PostOptions\MetaBox as PostOptionsMetaBox;
use PrettyLinks\Pro\Providers\CoreServiceProvider;
use PrettyLinks\Pro\Providers\HealthServiceProvider;
use PrettyLinks\Pro\Providers\PublicLinksServiceProvider;
use PrettyLinks\Pro\Providers\RedirectServiceProvider;
use PrettyLinks\Pro\Providers\ReplacementsServiceProvider;
use PrettyLinks\Pro\Providers\ReportsServiceProvider;
use PrettyLinks\Pro\Expiration\ExpirationChecker;
use PrettyLinks\Pro\Expiration\TrashSweeper as ExpirationTrashSweeper;
use PrettyLinks\Pro\Health\EmailDigest as HealthEmailDigest;
use PrettyLinks\Pro\Health\HealthChecker;
use PrettyLinks\Pro\Health\HealthStatusFilter;
use PrettyLinks\Pro\Expiration\ExpiredLinksFilter;
use PrettyLinks\Pro\Search\TermSearchFilter;
use PrettyLinks\Pro\Health\Scheduler as HealthScheduler;
use PrettyLinks\Pro\PrettyBar\BarRenderer;
use PrettyLinks\Pro\Qr\QrService;
use PrettyLinks\Pro\Redirect\MuPluginInstaller;
use PrettyLinks\Pro\RedirectTypes\CloakRenderer;
use PrettyLinks\Pro\RedirectTypes\JavascriptRenderer;
use PrettyLinks\Pro\RedirectTypes\MetaRefreshRenderer;
use PrettyLinks\Pro\RedirectTypes\PixelRenderer;
use PrettyLinks\Pro\Replacements\CacheInvalidator;
use PrettyLinks\Pro\Replacements\KeywordEngine;
use PrettyLinks\Pro\Replacements\KeywordIndexer;
use PrettyLinks\Pro\Replacements\KeywordRepository;
use PrettyLinks\Pro\Replacements\PostUrlCacheInvalidator;
use PrettyLinks\Pro\Replacements\ReplacementStyles;
use PrettyLinks\Pro\Replacements\UrlReplacer;
use PrettyLinks\Pro\Reports\SplitTestLinksFilter;
use PrettyLinks\Pro\Rest\BookmarkletController;
use PrettyLinks\Pro\Rest\CategoriesController;
use PrettyLinks\Pro\Rest\CustomReportsController;
use PrettyLinks\Pro\Rest\HealthController;
use PrettyLinks\Pro\Rest\KeywordsController;
use PrettyLinks\Pro\Rest\LinkPayloadExtension;
use PrettyLinks\Pro\Rest\PrettyBarController;
use PrettyLinks\Pro\Rest\PublicLinksController;
use PrettyLinks\Pro\Rest\QrPreviewController;
use PrettyLinks\Pro\Rest\ReportsController;
use PrettyLinks\Pro\Rest\SplitTestController;
use PrettyLinks\Pro\Rest\TagsController;
use PrettyLinks\Pro\Rest\ToolsController as ProToolsController;
use PrettyLinks\Pro\Rotation\RotationService;
use PrettyLinks\Pro\Rotation\RotationsRepository;
use PrettyLinks\Pro\PublicLinks\Service as PublicLinksService;
use PrettyLinks\Pro\Repositories\Categories as CategoriesRepo;
use PrettyLinks\Pro\Repositories\Tags as TagsRepo;
use PrettyLinks\Pro\SocialButtons\Service as SocialButtonsService;
use PrettyLinks\Pro\Tools\BookmarkletHandler;
use PrettyLinks\Pro\Tools\CsvExtension;
use PrettyLinks\Pro\Licensing\UpdateChecker;
use PrettyLinks\Licensing\LicenseClient;
use PrettyLinks\Licensing\LicenseManager;
use PrettyLinks\Pro\Targeting\Evaluator as TargetingEvaluator;
use PrettyLinks\Repositories\LinkMetas;
use PrettyLinks\Repositories\Links;

/**
 * Pretty Links Pro bootstrap.
 *
 * Wires Pro services into the main plugin's DI container and registers
 * the Pro-only hooks on top of the Lite engine's filter surface.
 */
class Bootstrap
{
    /**
     * The main plugin's DI container, stored on load().
     *
     * @var Container|null
     */
    private $container;

    /**
     * Loads Pro services, hooks, and REST routes.
     *
     * @param Container $container The main plugin's DI container.
     */
    public function load(Container $container): void
    {
        $this->container = $container;

        load_plugin_textdomain('pretty-link-pro');

        // Run the Pro schema installer synchronously before anything else so
        // Pro tables are guaranteed to exist when seedProOptions, service
        // factories, or hook registrations first touch them. Lite already
        // ran its own Migrator before firing `prli_loaded`, so Lite's core
        // tables are in place by the time we get here. maybeRun() is guarded
        // by a version check in prlipro_migration_state, so steady-state
        // loads just do one option read.
        global $wpdb;
        (new ProMigrator($wpdb, \PrettyLinks\Bootstrap::version()))->maybeRun();

        $this->seedProOptions();
        $this->registerServices($container);
        $this->registerHooks($container);

        // Kick the updater — registers pre_set_site_transient_update_plugins
        // and plugins_api filters.
        $container->get(UpdateChecker::class)->register();
    }

    /**
     * Whether the site has ever used the public (anonymous-visitor) link
     * creation flow. Fresh installs report false so the admin UI hides the
     * legacy block; upgraded sites or sites with existing public-source
     * links stay visible so admins can still manage them.
     */
    private static function publicLinksInUse(): bool
    {
        $pro = get_option('prlipro_options');
        if (is_array($pro) && !empty($pro['allow_public_link_creation'])) {
            return true;
        }
        if (is_array($pro) && !empty($pro['public_link_display_page'])) {
            return true;
        }
        return (new Links())->existsBySource('public');
    }

    /**
     * Extra options for the geo-targeting country picker, gathered from the
     * `prli_targeting_countries` filter and normalized for the admin bundle.
     *
     * The v4 replacement for v3's `plp_search_countries_list`. Each returned entry
     * is `['value' => CODE, 'label' => 'Name']`; the admin picker merges these
     * ahead of its built-in ISO-3166 list. Pair with `plp_locate_by_ip` to
     * resolve visitors to a custom aggregate code (e.g. `EU` for EU-member
     * countries).
     *
     * Codes are upper-cased and labels sanitized; blank codes and duplicates
     * are dropped. Codes already present in the built-in list are dropped by
     * the admin picker, so a hook can't duplicate or relabel a built-in entry.
     * No backend whitelist change is needed — Targeting\Evaluator already
     * matches `country_in` with a plain in_array().
     *
     * Public so it can be unit-tested directly (the admin bootstrap filter
     * that consumes it isn't registered in the test harness).
     *
     * @return array<int, array{value: string, label: string}>
     */
    public static function targetingCountryAdditions(): array
    {
        /**
         * Filter: prli_targeting_countries
         *
         * Additional country options for the geo-targeting rule picker
         * (`country_in` / `country_not_in`). Return a list of
         * `['value' => 'CODE', 'label' => 'Display name']` entries to merge
         * ahead of the built-in country list in the link editor.
         *
         * @param array<int, array{value: string, label: string}> $extra Defaults to [].
         */
        $extra = apply_filters('prli_targeting_countries', []);
        if (!is_array($extra)) {
            return [];
        }

        $out  = [];
        $seen = [];
        foreach ($extra as $item) {
            if (!is_array($item)) {
                continue;
            }
            $value = strtoupper(trim((string) ($item['value'] ?? '')));
            if ($value === '' || isset($seen[$value])) {
                continue;
            }
            $label = sanitize_text_field((string) ($item['label'] ?? ''));
            if ($label === '') {
                $label = $value;
            }
            $seen[$value] = true;
            $out[]        = [
                'value' => $value,
                'label' => $label,
            ];
        }

        return $out;
    }

    /**
     * Seed `prlipro_options` when pro is active. Keeps the free tier's DB
     * clean (free activator skips this) while still populating defaults on
     * any install where pro code is loaded — whether activated with pro
     * present or added after a free-only activation.
     *
     * Truly fresh pro installs (no links yet) get `base_slug_prefix` = 'go'
     * as the Pretty Links house default. Pro installs with existing links
     * (v3 Lite → v4 Pro upgrade, or Lite → Pro mid-use) stay on '' so
     * existing link URLs don't silently shift from /slug to /go/slug.
     */
    private function seedProOptions(): void
    {
        if (is_array(get_option(OptionsStore::OPTION))) {
            return;
        }
        $defaults = OptionsStore::defaults();
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        $hasLinks = (bool) $wpdb->get_var("SELECT 1 FROM {$wpdb->prefix}prli_links LIMIT 1");
        if (!$hasLinks) {
            $defaults['base_slug_prefix'] = 'go';
        }
        add_option(OptionsStore::OPTION, $defaults, '', 'yes');
    }

    /**
     * Registers the Pro domain service providers on the container.
     *
     * Each provider binds a related group of Pro services as lazy singleton
     * closures, so registration order doesn't matter — nothing resolves until
     * registerHooks() pulls services out of the container.
     *
     * @param Container $container The main plugin's DI container.
     */
    private function registerServices(Container $container): void
    {
        // Service bindings are grouped into domain providers under
        // pro/src/Providers/ so adding a new Pro service means editing (or
        // adding) a focused provider rather than this orchestrator. Bindings
        // are lazy singleton closures, so registration order across providers
        // doesn't matter — nothing resolves until registerHooks() runs. Core
        // is listed first only for readability.
        $container
            ->provider(CoreServiceProvider::class)
            ->provider(ReplacementsServiceProvider::class)
            ->provider(RedirectServiceProvider::class)
            ->provider(ReportsServiceProvider::class)
            ->provider(HealthServiceProvider::class)
            ->provider(PublicLinksServiceProvider::class);
    }

    /**
     * Wires every Pro hook onto the Lite engine's filter and action surface.
     *
     * Resolves the Pro services from the container, then registers the
     * content/comment/feed replacement filters, redirect-type handlers,
     * admin menus and assets, REST controllers, scheduled health checks,
     * onboarding contributions, and the rest of Pro's WordPress integrations.
     *
     * @param Container $container The main plugin's DI container.
     */
    private function registerHooks(Container $container): void
    {
        $engine      = $container->get(KeywordEngine::class);
        $urlReplacer = $container->get(UrlReplacer::class);
        $indexer     = $container->get(KeywordIndexer::class);
        $invalidator = $container->get(CacheInvalidator::class);
        $rotation    = $container->get(RotationService::class);
        $targeting   = $container->get(TargetingEvaluator::class);
        $expiration  = $container->get(ExpirationChecker::class);
        $healthCheck = $container->get(HealthChecker::class);
        $healthSched = $container->get(HealthScheduler::class);
        $healthEmail = $container->get(HealthEmailDigest::class);
        $autoCreate  = $container->get(AutoCreator::class);
        $barRenderer = $container->get(BarRenderer::class);
        $qrService   = $container->get(QrService::class);

        // UpdateChecker refresh hooks. The updater itself lives in Pro because
        // WP.org prohibits plugin updaters in hosted plugins; the refresh
        // listeners follow it here. The in_plugin_update_message- display hook
        // stays in Lite since it's not an updater mechanism.
        $updateChecker = $container->get(UpdateChecker::class);
        add_action('prli_license_activated_before_queue_update', [$updateChecker, 'refreshUpdateInfo']);
        add_action('prli_license_deactivated_before_queue_update', [$updateChecker, 'refreshUpdateInfo']);
        add_action('prli_license_edge_updates_changed', [$updateChecker, 'refreshUpdateInfo']);

        // Self-heal a crashed mu-plugin dispatch before the Engine runs on init@1.
        add_action('init', [MuPluginInstaller::class, 'maybeSelfHeal'], 0);

        // Remove the MU-plugin dispatcher when Lite is deactivated — Lite
        // fires this action from its register_deactivation_hook so Pro can
        // clean up artifacts it dropped outside the plugin directory.
        add_action('prli_plugin_deactivating', [MuPluginInstaller::class, 'removeIfExists']);

        // Merge per-source JS translation JSON onto Pro's built bundles,
        // reusing Lite's domain-agnostic merger for the Pro text domain.
        add_filter('pre_load_script_translations', static function ($pre, $file, $handle, $domain) {
            if ($pre !== null || $domain !== 'pretty-link-pro') {
                return $pre;
            }
            return ScriptTranslations::localeData('pretty-link-pro');
        }, 10, 4);

        // Swap the top-bar "Support" link from wordpress.org to
        // prettylinks.com for paid customers.
        add_filter('prli_support_url', static function (): string {
            return 'https://prettylinks.com/support/';
        });

        // URL scheme validation — when `no_enforce_url_scheme` is on, bypass
        // Lite's http(s):// requirement so any scheme (mailto:, tel:, etc.) is allowed.
        add_filter('prli_is_valid_url', static function (bool $valid, string $url): bool {
            unset($url);
            $opts = get_option('prlipro_options');
            if (is_array($opts) && !empty($opts['no_enforce_url_scheme'])) {
                return true;
            }
            return $valid;
        }, 10, 2);

        // Admin capability — v3 Pro stored this in prlipro_options['min_role'].
        // Lite fires `prli_admin_capability`; Pro feeds in the stored value so
        // the setting is honored without Lite knowing about Pro.
        add_filter('prli_admin_capability', static function (string $cap): string {
            $opts = get_option('prlipro_options');
            $role = is_array($opts) && !empty($opts['min_role']) ? (string) $opts['min_role'] : '';
            return $role !== '' ? $role : $cap;
        });

        // Alternate shortlink domain. When `use_prettylink_url` is on and
        // `prettylink_url` is non-empty, all pretty URLs render on the
        // configured alternate host instead of home_url().
        add_filter('prli_pretty_link_base', static function (string $base): string {
            $opts = (array) (get_option('prlipro_options', []) ?: []);
            if (empty($opts['use_prettylink_url'])) {
                return $base;
            }
            $alt = isset($opts['prettylink_url']) ? trim((string) $opts['prettylink_url']) : '';
            return $alt !== '' ? $alt : $base;
        });

        // Admin React bootstrap payload — add Pro's flags so Pro-side JS
        // bundles can read them. Lite doesn't emit these itself.
        add_filter('prli_admin_bootstrap', static function (array $payload, string $bundle): array {
            unset($bundle);
            // `isPro` / `proState.*` are emitted by Lite — both Lite and
            // Pro agree on the signal via ProState now.
            $payload['publicLinksInUse']   = self::publicLinksInUse();
            $proOpts                       = get_option('prlipro_options');
            $payload['noEnforceUrlScheme'] = is_array($proOpts) && !empty($proOpts['no_enforce_url_scheme']);
            $payload['qrLogoSizeDefault']  = is_array($proOpts) ? (int) ($proOpts['qr_logo_size_percent'] ?? 20) : 20;
            // QR-codes master toggle (Options → QR Codes → Generate QR codes).
            // The link form's QR tab reads this to hide itself when QR is off
            // site-wide.
            $payload['qrCodesEnabled']   = is_array($proOpts) && !empty($proOpts['generate_qr_codes']);
            // Single nonce covering every link's QR download link (list row
            // action + link-form QR tab, both PNG and SVG). WP nonces bind
            // to user + action + time-tick, not to a specific link id, so
            // one bootstrapped value authorizes every `?download=<nonce>`
            // request on this page load — matches the check in
            // QrService::isDownloadRequest().
            $payload['qrDownloadNonce']  = wp_create_nonce('prli-generate-qr-code');
            $payload['enableLinkHealth'] = is_array($proOpts) && !empty($proOpts['enable_link_health']);
            // Drives the Keywords column visibility in the Links list and
            // gates the Keywords group on the link form's Replacements tab.
            // Setting: Pro Options → Replacements → Enable keyword replacement.
            $payload['keywordReplacementEnabled'] = is_array($proOpts) && !empty($proOpts['keyword_replacement_is_on']);
            // Gates the URL Replacements group on the link form's Replacements
            // tab. When both this and keywordReplacementEnabled are false,
            // the Replacements tab itself is hidden.
            // Setting: Pro Options → Replacements → Enable URL replacement.
            $payload['urlReplacementEnabled'] = is_array($proOpts) && !empty($proOpts['url_replacement_is_on']);
            $payload['hasSplitTestLinks']     = SplitTestLinksFilter::anyEnabled();
            // Drives conditional "Broken" / "Expired" filter tabs in the
            // Links-list filter bar (matches the Split Tests segment
            // pattern — only surfaces when the site has matching rows).
            $payload['hasBrokenLinks']  = HealthStatusFilter::anyBroken();
            $payload['hasExpiredLinks'] = ExpiredLinksFilter::anyExpired();
            // Tags are deprecated in 4.0. Only sites that already have at
            // least one tag (migrated from v3 or created via the Tags page
            // URL directly) get the tag UI — menu item, link-list column,
            // link-form picker, and bulk actions all gate on this flag.
            $payload['hasTags'] = \PrettyLinks\Pro\Repositories\Tags::hasAny();
            // Per-link Pretty Bar template picker — the link-edit form reads
            // this to populate a dropdown next to the redirect-type picker
            // when the admin chooses Pretty Bar. Kept here (not behind a
            // REST fetch) so the picker renders instantly on form mount.
            $payload['prettyBarTemplates'] = [
                [
                    'id'    => \PrettyLinks\Pro\PrettyBar\Templates\TopBarTemplate::ID,
                    'label' => (new \PrettyLinks\Pro\PrettyBar\Templates\TopBarTemplate())->label(),
                ],
                [
                    'id'    => \PrettyLinks\Pro\PrettyBar\Templates\BottomBarTemplate::ID,
                    'label' => (new \PrettyLinks\Pro\PrettyBar\Templates\BottomBarTemplate())->label(),
                ],
                [
                    'id'    => \PrettyLinks\Pro\PrettyBar\Templates\FloatingTemplate::ID,
                    'label' => (new \PrettyLinks\Pro\PrettyBar\Templates\FloatingTemplate())->label(),
                ],
            ];
            // Site has at least one link still rendering via the v3
            // Classic Pretty Bar (redirect_type=prettybar, no template
            // meta). Drives the conditional "Classic (v3)" option in the
            // per-link template picker and the Classic settings block
            // on the Options → Pretty Bar page. Fresh 4.0 installs
            // report false and never see either UI.
            $payload['hasLegacyPrettyBarLinks'] = \PrettyLinks\Pro\PrettyBar\Settings::hasLegacyLinks();
            // Extra geo-targeting country options (v3 `plp_search_countries_list`
            // parity). The `country_in` / `country_not_in` picker's base list is
            // compiled into the JS bundle, so custom aggregate codes — e.g. an
            // "EU" that `plp_locate_by_ip` resolves to — can't otherwise be
            // surfaced in the admin. The runtime already matches any code
            // (Targeting\Evaluator uses a plain in_array, no whitelist).
            $payload['targetingCountries'] = self::targetingCountryAdditions();
            return $payload;
        }, 10, 2);

        // Options REST surface — Lite only handles its own `lite` key; Pro
        // reads/writes `pro` on top of the shared GET/PUT endpoint.
        add_action('prli_options_update', static function (array $body): void {
            if (!empty($body['pro']) && is_array($body['pro'])) {
                $proStore = new OptionsStore();
                $proStore->merge(array_intersect_key($body['pro'], $proStore->defaults()));
            }
        });
        add_filter('prli_options_response', static function (array $payload): array {
            // Social_buttons is normalised once by the Pro migrator
            // (normalise_social_buttons step), so the stored option is already
            // the canonical {slug, enabled} shape the admin reads — no
            // read-time reshaping needed.
            $payload['pro'] = (new OptionsStore())->all();
            return $payload;
        });

        // Onboarding wizard — contribute Pro-feature rows, record Pro
        // options in the snapshot, and persist selections.
        add_filter('prli_onboarding_extra_option_snapshot', static function (array $snapshot): array {
            return array_merge($snapshot, (new OptionsStore())->all());
        });
        add_filter('prli_onboarding_feature_labels', static function (array $labels, array $post): array {
            $store = new OptionsStore();
            $store->set('enable_link_health', !empty($post['prli_feature_link_health']));
            $store->set('keyword_replacement_is_on', !empty($post['prli_feature_keyword_enable']));
            if (!empty($post['prli_feature_link_health'])) {
                $labels[] = __('Link Health', 'pretty-link-pro');
            }
            if (!empty($post['prli_feature_keyword_enable'])) {
                $labels[] = __('Keyword Replacements', 'pretty-link-pro');
            }
            return $labels;
        }, 10, 2);
        add_action('prli_onboarding_render_feature_rows', static function (): void {
            $opts = (new OptionsStore())->all();
            \PrettyLinks\Onboarding\Wizard::renderFeatureItem(
                'prli_feature_link_health',
                __('Link Health', 'pretty-link-pro'),
                __('Periodically check that your target URLs still respond.', 'pretty-link-pro'),
                __('Affiliate programs expire, merchants change URLs, and pages 404 without warning — silently killing the links you\'ve scattered across blog posts. Link Health runs a background check on every destination on a schedule and emails you when one breaks, so you can fix it before readers hit a dead end.', 'pretty-link-pro'),
                !empty($opts['enable_link_health']),
                true
            );
            \PrettyLinks\Onboarding\Wizard::renderFeatureItem(
                'prli_feature_keyword_enable',
                __('Keyword Replacements', 'pretty-link-pro'),
                __('Automatically replace configured keywords in your content with Pretty Links.', 'pretty-link-pro'),
                __('Define a keyword once — say "best hosting" — and Pretty Links scans your existing and future posts, swapping those words for the matching affiliate link automatically. Lets you monetise old content retroactively without opening each post, and when the affiliate program changes you only update the link in one place.', 'pretty-link-pro'),
                !empty($opts['keyword_replacement_is_on']),
                true
            );
        });

        // Extend Lite's /links REST payload with Pro-only fields
        // (categories, tags, keywords, splitTest, expiration, rotations,
        // targeting, prettybar_template, plus the pro bulk actions).
        (new LinkPayloadExtension(
            $container->get(Links::class),
            $container->get(LinkMetas::class),
            $container->get(RotationsRepository::class),
            $container->get(TargetingEvaluator::class)
        ))->register();

        // Per-source URL prefix resolver for the `'admin'` and `'public'`
        // sources. The `'user'` branch is owned by the User Links add-on
        // (pretty-link-user-links), which adds its own listener on the
        // same filter — multiple listeners coexist by checking $source.
        add_filter('prli_link_prefix_for_source', static function (string $default, string $source): string {
            $opts = (array) (get_option('prlipro_options', []) ?: []);
            if ($source === 'admin') {
                return trim((string) ($opts['base_slug_prefix'] ?? ''), '/');
            }
            if ($source === 'public') {
                $public = trim((string) ($opts['public_link_prefix'] ?? 'p'), '/');
                return $public !== '' ? $public : $default;
            }
            return $default;
        }, 10, 2);

        // Redirect-type catalog. Lite publishes the base 302/301 list and
        // the deprecated-but-still-supported 307; Pro adds its five
        // extras so the link editor dropdown and REST payloads know about
        // them. Labels are Pro's authoritative source.
        //
        // Pretty Bar is only listed when its master toggle is on — keeps
        // it out of the link-edit dropdown, the Links-list filter, and
        // the REDIRECT_TYPES catalog consumed by front-end components.
        // Existing links with `redirect_type = 'prettybar'` are handled
        // separately: the runtime filter below (line ~945) coerces their
        // actual redirect to a 302, and LinkForm's unknown-type coercion
        // effect reads 'prettybar' as '302' for display/save because it
        // falls out of the known-types set once this filter drops it.
        add_filter('prli_redirect_types', static function (array $types): array {
            $types[] = [
                'value' => 'cloak',
                'label' => __('Cloaked', 'pretty-link-pro'),
            ];
            if (BarRenderer::isActive()) {
                $types[] = [
                    'value' => 'prettybar',
                    'label' => __('Pretty Bar', 'pretty-link-pro'),
                ];
            }
            $types[] = [
                'value' => 'pixel',
                'label' => __('Tracking pixel', 'pretty-link-pro'),
            ];
            $types[] = [
                'value' => 'metarefresh',
                'label' => __('Meta refresh', 'pretty-link-pro'),
            ];
            $types[] = [
                'value' => 'javascript',
                'label' => __('JavaScript redirect', 'pretty-link-pro'),
            ];
            return $types;
        });

        // Content filter chain. Priorities preserved from v3 via filter
        // hooks so third-party plugins can still shift order if needed.
        // Keyword runs after URL to match v3 (lower priority number runs
        // earlier; v3 has URL=998, keyword=999).
        // Automatic excerpts need no hook of their own: both engines check
        // `doing_filter('get_the_excerpt')` inline, since WordPress is still
        // inside that filter when `wp_trim_excerpt()` applies `the_content`.
        // v3 bound an unbind here that never fired (wrong priority).
        $urlPriority     = (int) apply_filters('prli_urls_content_filter_priority', 998);
        $keywordPriority = (int) apply_filters('prli_keywords_content_filter_priority', 999);
        add_filter('the_content', [$urlReplacer, 'filter'], $urlPriority);
        add_filter('the_content', [$engine, 'filter'], $keywordPriority);

        // Scope-specific filters. Each method early-returns when the
        // scope option doesn't permit its replacement type, so registering
        // them unconditionally is safe and mirrors v3's wiring.
        add_filter('comment_text', [$engine, 'filterComment'], 1);
        add_filter('comment_text', [$urlReplacer, 'filterComment'], 1);
        add_filter('comment_text_rss', [$engine, 'filterCommentRss'], 1);
        add_filter('comment_text_rss', [$urlReplacer, 'filterCommentRss'], 1);
        add_filter('the_content_feed', [$engine, 'filterFeed'], 1);
        add_filter('the_content_feed', [$urlReplacer, 'filterFeed'], 1);

        // BBPress replies + WooCommerce short descriptions.
        add_filter('bbp_get_reply_content', [$engine, 'filterBbpress'], 11);
        add_filter('bbp_get_reply_content', [$urlReplacer, 'filterBbpress'], 11);
        add_filter('woocommerce_short_description', [$engine, 'filterBbpress'], 11);
        add_filter('woocommerce_short_description', [$urlReplacer, 'filterBbpress'], 11);

        // V3 parity: `global_head_scripts` injected raw into cloaked pages
        // (pretty bar + cloak iframe). Unsanitized by design — this is the
        // hook site admins use for GA snippets, affiliate pixels, etc.
        $emitGlobalHeadScripts = static function (): void {
            $opts    = (array) (get_option('prlipro_options', []) ?: []);
            $scripts = isset($opts['global_head_scripts']) ? (string) $opts['global_head_scripts'] : '';
            if ($scripts !== '') {
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- v3 parity: admin-authored head scripts (GA/affiliate pixels) echoed raw by design.
                echo $scripts;
            }
        };
        add_action('prli-pretty-bar-head', $emitGlobalHeadScripts);
        add_action('prli_cloak_head', $emitGlobalHeadScripts);

        // V3 parity: per-link `head_scripts` meta — echoed raw into the
        // cloaked redirect page's <head>. Unsanitized by design (same as
        // v3) — affiliate pixels, per-destination GA snippets, etc.
        $linkMetas              = $container->get(LinkMetas::class);
        $emitPerLinkHeadScripts = static function ($link) use ($linkMetas): void {
            $linkId = is_array($link) ? (int) ($link['id'] ?? 0) : 0;
            if ($linkId <= 0) {
                return;
            }
            $scripts = (string) $linkMetas->get($linkId, 'head_scripts', '');
            if ($scripts !== '') {
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- v3 parity: admin-authored per-link head scripts echoed raw by design.
                echo wp_unslash($scripts);
            }
        };
        add_action('prli-pretty-bar-head', $emitPerLinkHeadScripts);
        add_action('prli_cloak_head', $emitPerLinkHeadScripts);
        add_action('prli_javascript_redirect_head', $emitGlobalHeadScripts);
        add_action('prli_javascript_redirect_head', $emitPerLinkHeadScripts);
        add_action('prli_metarefresh_head', $emitGlobalHeadScripts);
        add_action('prli_metarefresh_head', $emitPerLinkHeadScripts);

        // V3 parity: inject admin-authored CSS into wp_head so replaced
        // keyword anchors and URL-replaced anchors can be styled
        // site-wide without theme edits.
        add_action('wp_head', static function (): void {
            ReplacementStyles::emit(
                'prli-keyword-link-css',
                'a.prli-keyword',
                'keyword_link_custom_css',
                'keyword_link_hover_custom_css'
            );
        }, 100);
        add_action('wp_head', static function (): void {
            ReplacementStyles::emit(
                'prli-url-link-css',
                'a.prli-url',
                'url_replacement_link_custom_css',
                'url_replacement_link_hover_custom_css'
            );
        }, 100);

        // Per-post options meta box (disable replacements, hide social, requested slug).
        // Priority 10 writes the requested-slug meta before AutoCreator reads it at 20.
        $postOptionsBox = new PostOptionsMetaBox($container->get(OptionsStore::class));
        add_action('add_meta_boxes', [$postOptionsBox, 'register']);
        add_action('save_post', [$postOptionsBox, 'save'], 10, 1);

        // Auto-create pretty links on post publish (runs before the indexer so
        // the keyword/URL caches see the new link on first save).
        add_action('save_post', [$autoCreate, 'onSavePost'], 20, 2);
        // Backfill existing posts on first singular view after auto-create is
        // enabled or its category is changed. Signature-gated, so repeat views
        // are a single post-meta read.
        add_action('template_redirect', [$autoCreate, 'onSingularView']);

        // Indexer on post save.
        add_action('save_post', [$indexer, 'onSavePost'], 20, 2);

        // Per-post URL replacement cache invalidation. All logic lives
        // in PostUrlCacheInvalidator — Bootstrap just binds events.
        $urlCacheInvalidator = $container->get(PostUrlCacheInvalidator::class);
        add_action('save_post', [$urlCacheInvalidator, 'onSavePost'], 10, 1);
        add_action('delete_post', [$urlCacheInvalidator, 'onDeletePost'], 10, 1);
        add_action('prli-create-link', [$urlCacheInvalidator, 'onLinkCreated'], 10, 2);
        add_action('prli_update_link', [$urlCacheInvalidator, 'onLinkUpdated'], 10, 1);
        add_action('prli_delete_link', [$urlCacheInvalidator, 'onLinkDeleted'], 10, 1);
        add_action('prli_link_save_url_replacements', [$urlCacheInvalidator, 'onUrlReplacementsSaved'], 10, 3);
        add_action('update_option_prlipro_options', [$urlCacheInvalidator, 'onOptionsChanged'], 10, 2);

        // Keyword index depends on site-wide options (case-sensitive toggle,
        // ignore list, eligible post types). When any of them change the
        // persisted index doesn't reflect the new rules — re-index lazily.
        add_action('update_option_prlipro_options', [$invalidator, 'onOptionsChanged'], 10, 2);

        // Link row mutations (edit, soft-delete, restore) change the joined
        // columns returned by KeywordRepository::all() — clear the list cache.
        add_action('prli_update_link', [$invalidator, 'onLinkMutated']);
        add_action('prli_delete_link', [$invalidator, 'onLinkMutated']);

        // Per-link keywords — Lite LinksController dispatches these hooks
        // on link save/show. Pro listens, persists to prli_keywords, logs
        // adds so posts catch up lazily, and clears the list cache.
        $keywordRepo = $container->get(KeywordRepository::class);
        add_action(
            'prli_link_save_keywords',
            static function ($linkId, $keywords) use ($keywordRepo, $invalidator): void {
                $diff = $keywordRepo->setForLink((int) $linkId, is_array($keywords) ? $keywords : []);
                // Log every freshly-created keyword so posts with stale stamps
                // know to scan their content for it. Removals cascaded in
                // setForLink; no stamp invalidation needed because the post↔
                // keyword mapping for those rows has been cleaned already.
                foreach (($diff['added_keywords'] ?? []) as $added) {
                    $invalidator->onKeywordAdded(
                        (int) $added['id'],
                        (string) $added['text']
                    );
                }
                if (($diff['removed'] ?? 0) > 0 && ($diff['added'] ?? 0) === 0) {
                    $invalidator->onKeywordRemoved();
                }
            },
            10,
            2
        );
        add_filter(
            'prli_link_load_keywords',
            static function ($default, $linkId) use ($keywordRepo): array {
                unset($default);
                return $keywordRepo->textForLink((int) $linkId);
            },
            10,
            2
        );
        add_filter(
            'prli_link_load_keywords_bulk',
            static function ($default, $linkIds) use ($keywordRepo): array {
                unset($default);
                return $keywordRepo->textForLinks(is_array($linkIds) ? $linkIds : []);
            },
            10,
            2
        );

        // Bulk category + tag term lookup for the Lite /links REST index.
        // Repositories own the SQL; this listener only exists to seed a
        // default row shape for link ids with no terms.
        $categoriesRepo = $container->get(CategoriesRepo::class);
        $tagsRepo       = $container->get(TagsRepo::class);
        add_filter(
            'prli_link_load_terms_bulk',
            static function ($default, $linkIds) use ($categoriesRepo, $tagsRepo): array {
                unset($default);
                $ids = array_values(array_unique(array_filter(array_map('intval', (array) $linkIds))));
                $out = [];
                foreach ($ids as $id) {
                    $out[$id] = [
                        'categories' => [],
                        'tags'       => [],
                    ];
                }
                if (!$ids) {
                    return $out;
                }
                $cats = $categoriesRepo->namesForLinks($ids);
                $tags = $tagsRepo->namesForLinks($ids);
                foreach ($cats as $lid => $rows) {
                    if (isset($out[$lid])) {
                        $out[$lid]['categories'] = $rows;
                    }
                }
                foreach ($tags as $lid => $rows) {
                    if (isset($out[$lid])) {
                        $out[$lid]['tags'] = $rows;
                    }
                }
                return $out;
            },
            10,
            2
        );

        // Onboarding wizard filters — Lite Wizard.php calls these when the
        // user is on the Pro-only Category step, so pro gets to populate
        // the picker and create new categories.
        add_filter(
            'prli_categories_all',
            static function ($default) use ($categoriesRepo): array {
                unset($default);
                return $categoriesRepo->all();
            }
        );
        add_filter(
            'prli_category_create',
            static function ($default, $data) use ($categoriesRepo) {
                unset($default);
                return $categoriesRepo->create(is_array($data) ? $data : []);
            },
            10,
            2
        );
        // Hard-delete: remove keyword rows, rotations, and wipe the keyword index.
        // Soft-delete/restore leaves them alone so "restore" still brings them back.
        $rotationsRepo = $container->get(RotationsRepository::class);
        add_action(
            'prli_delete_link',
            static function ($linkId, $context) use ($keywordRepo, $rotationsRepo, $invalidator): void {
                if (is_array($context) && !empty($context['soft'])) {
                    return;
                }
                $id = (int) $linkId;
                $keywordRepo->setForLink($id, []);
                $rotationsRepo->deleteForLink($id);
                $invalidator->invalidateAll();
            },
            10,
            2
        );

        // Inject Pro page ranks into the shared submenu sort map. Lite
        // can't know Pro slugs directly (isolation rule), so the final
        // Dashboard → Pretty Links → Add New → PrettyPay → Click History →
        // Categories → Tags → Custom Reports → Options → Add-ons →
        // Growth Tools order is composed here. (Tools moved into Options
        // as a sidebar section, so it no longer appears as a submenu).
        add_filter('prli_admin_submenu_order', static function (array $order): array {
            $order['pretty-link-categories']     = 50;
            $order['pretty-link-tags']           = 60;
            $order['pretty-link-custom-reports'] = 70;
            return $order;
        });

        // Admin submenu — Pro pages. Priority 11 matches the Lite pages.
        add_action('admin_menu', [CategoriesPage::class, 'register'], 11);
        // Tags: deprecated in 4.0. Only register the page (menu + URL)
        // when the site already has at least one tag. New installs get no
        // menu entry and no working URL — the feature stays invisible.
        add_action('admin_menu', static function (): void {
            if (\PrettyLinks\Pro\Repositories\Tags::hasAny()) {
                TagsPage::register();
            }
        }, 11);
        add_action('admin_menu', [SplitTestReportPage::class, 'register'], 11);
        add_action('admin_menu', [CustomReportsPage::class, 'register'], 11);

        // Pro admin asset enqueue. Pro bundles ship in `pro/assets/js/build/`
        // and only mount on Lite pages that render the corresponding Pro
        // placeholder div. This is what physically gates Pro UI on Pro
        // detection — the React bundle source itself lives in `pro/src-js/`
        // and is stripped from Lite distributions.
        ProAdminAssets::setContainer($container);
        add_action('admin_enqueue_scripts', [ProAdminAssets::class, 'enqueue']);

        // REST endpoints.
        add_action('rest_api_init', function () use ($container): void {
            (new BookmarkletController($container))->register();
            (new CategoriesController($container))->register();
            (new TagsController($container))->register();
            (new KeywordsController($container))->register();
            (new PrettyBarController($container))->register();
            (new QrPreviewController($container))->register();
            (new SplitTestController($container))->register();
            (new CustomReportsController($container))->register();
            (new ReportsController($container))->register();
            (new HealthController($container))->register();
            (new ProToolsController($container))->register();
            (new PublicLinksController($container))->register();
        });

        // CSV import/export: add Pro columns (link_categories, link_tags, delay).
        (new CsvExtension())->init();

        // Bookmarklet frontend handler — listens for `?prli_bookmarklet=TOKEN`
        // at priority 1 on `init`, before WP tries to parse it as a query.
        $bookmarkletHandler = new BookmarkletHandler($container->get(Links::class));
        add_action('init', [$bookmarkletHandler, 'maybeHandle'], 1);

        // Social share buttons — shortcode + content injection + assets.
        $social = $container->get(SocialButtonsService::class);
        $social->registerShortcode();
        $social->registerContentFilter();
        add_action('wp_enqueue_scripts', [$social, 'enqueueAssets']);

        // Public (anonymous visitor) link creation — shortcodes always
        // register so templates don't break; the handler + form itself
        // only activate when `allow_public_link_creation` is on.
        $publicLinks = $container->get(PublicLinksService::class);
        $publicLinks->registerShortcodes();
        add_action('wp_enqueue_scripts', [$publicLinks, 'enqueueAssets']);

        // Redirect integrations.
        add_filter('prli_target_url', [$expiration, 'filter'], 5, 1);
        add_filter('prli_target_url', [$targeting, 'filter'], 10, 1);
        add_filter('prli_target_url', [$rotation, 'filter'], 20, 1);
        add_action('prli_click_written', [$rotation, 'recordRotationClick'], 10, 2);

        // Lite clears its click tables on a tracking-mode transition across
        // the Simple boundary. Follow suit for Pro's rotation-click audit
        // table so its foreign keys to prli_clicks don't go stale.
        add_action('prli_click_data_wiped', static function (): void {
            global $wpdb;
            $wpdb->query("DELETE FROM {$wpdb->prefix}prli_clicks_rotations");
        });

        // Pretty Bar kill-switch: downgrade `prettybar` → `302` upstream of the
        // renderer when the feature is disabled site-wide, so existing links
        // fall back to a standard redirect without rendering the bar.
        add_filter(
            'prli_resolved_redirect_type',
            static function (string $type): string {
                if ($type === 'prettybar' && !BarRenderer::isActive()) {
                    return '302';
                }
                return $type;
            },
            10,
            1
        );

        // Cloaked redirects (Pretty Bar + plain cloak iframe).
        add_action('prli_issue_cloaked_redirect', [$barRenderer, 'handle'], 10, 5);
        add_action('prli_issue_cloaked_redirect', [$container->get(CloakRenderer::class), 'handle'], 10, 5);

        // Non-HTTP redirect types (pixel, meta refresh, JS redirect).
        add_filter('prli_handle_redirect_type', [$container->get(PixelRenderer::class), 'filter'], 10, 5);
        add_filter('prli_handle_redirect_type', [$container->get(MetaRefreshRenderer::class), 'filter'], 10, 5);
        add_filter('prli_handle_redirect_type', [$container->get(JavascriptRenderer::class), 'filter'], 10, 5);

        // Special routes (QR codes).
        add_filter('prli_handle_special_route', [$qrService, 'filter'], 10, 4);

        // Link Health scheduler + cron event.
        add_filter('cron_schedules', [HealthEmailDigest::class, 'cronSchedules']);
        $healthSched->boot();
        $healthEmail->ensureScheduled();
        // Both the recurring event and the ad-hoc hook (chain / retry / force)
        // drive the same batched runner.
        add_action(HealthScheduler::HOOK, [$healthCheck, 'runCheck']);
        add_action(HealthScheduler::ADHOC_HOOK, [$healthCheck, 'runCheck']);
        add_action(HealthEmailDigest::EVENT, [$healthEmail, 'run']);

        // Expired-link trash sweep: every 12h, hand date-expired trash-action
        // links to the Resque worker. Redirect-time expiration stays in place
        // (ExpirationChecker) — whichever reaches a link first wins.
        $trashSweeper = new ExpirationTrashSweeper();
        $trashSweeper->schedule();
        add_action(ExpirationTrashSweeper::HOOK, [$trashSweeper, 'enqueueSweep']);
        add_action('prli_plugin_deactivating', [ExpirationTrashSweeper::class, 'unschedule']);

        // Extend the Lite /links REST search with a `health=broken` filter.
        add_filter('prli_links_index_args', static function (array $args, $request): array {
            $health = is_object($request) && method_exists($request, 'get_param')
                ? (string) $request->get_param('health')
                : '';
            if ($health !== '') {
                $args['health'] = $health;
            }
            return $args;
        }, 10, 2);

        add_filter('prli_links_query_clauses', [new HealthStatusFilter(), 'apply'], 10, 2);
        add_filter('prli_links_search_clauses', [new TermSearchFilter(), 'apply'], 10, 3);

        // Extend the Lite /links REST search with an `expired=1` filter
        // that limits results to date-expired links.
        add_filter('prli_links_index_args', static function (array $args, $request): array {
            $flag = is_object($request) && method_exists($request, 'get_param')
                ? (string) $request->get_param('expired')
                : '';
            if ($flag !== '') {
                $args['expired'] = $flag;
            }
            return $args;
        }, 10, 2);
        add_filter('prli_links_query_clauses', [new ExpiredLinksFilter(), 'apply'], 10, 2);

        // Extend the Lite /links REST search with a `split_test=1` filter
        // that limits results to links with split testing enabled.
        add_filter('prli_links_index_args', static function (array $args, $request): array {
            $flag = is_object($request) && method_exists($request, 'get_param')
                ? (string) $request->get_param('split_test')
                : '';
            if ($flag !== '') {
                $args['split_test'] = $flag;
            }
            return $args;
        }, 10, 2);
        add_filter('prli_links_query_clauses', [new SplitTestLinksFilter(), 'apply'], 10, 2);

        (new Blocks\PixelBlock())->register();
    }
}
