<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Rest;

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\Pro\Health\HealthChecker;
use PrettyLinks\Pro\Redirect\DynamicMode;
use PrettyLinks\Pro\Rotation\RotationsRepository;
use PrettyLinks\Pro\Targeting\Evaluator as TargetingEvaluator;
use PrettyLinks\Repositories\LinkMetas;
use PrettyLinks\Repositories\Links as LinksRepo;

/**
 * Extends the Lite `/links` REST payload with Pro-only fields.
 *
 * Hooks the extension points Lite's `LinksController` publishes:
 *  - `prli_link_payload_pre_save`  — strip Pro fields off the payload
 *    before the Links repo touches it.
 *  - `prli_link_after_save`        — persist the Pro fields.
 *  - `prli_link_response_attach`   — attach Pro fields to a single
 *    link response.
 *  - `prli_link_response_attach_bulk` — attach Pro fields to a Links
 *    list response (bulk one-query reads so we don't N+1).
 *  - `prli_link_bulk_action`       — handle Pro-only bulk actions
 *    (`bulk_add_categories`, `bulk_add_tags`, etc.).
 *
 * Fields owned here:
 *  - `categories` / `tags`        — taxonomy assignments (Pro only).
 *  - `keywords`                   — keyword-replacement terms.
 *  - `splitTest`                  — `{enabled, goalLinkId}` meta.
 *  - `prettybar_template`         — per-link Pretty Bar template.
 */
class LinkPayloadExtension
{
    /**
     * Split-test link meta keys (v3-canonical hyphenated form).
     */
    private const META_SPLIT_TEST_ENABLED = 'prli-enable-split-test';
    private const META_SPLIT_TEST_GOAL    = 'prli-split-test-goal-link';

    /**
     * V3 "URL Replacements" list — one row per URL under this meta_key
     * on `prli_link_metas`. Multi-row pattern (unlike SIMPLE_META_KEYS)
     * so the CRUD path is handled inline rather than through LinkMetas
     * (which enforces one-row-per-(link,key)).
     */
    private const META_URL_REPLACEMENTS = 'prli-url-replacements';

    /**
     * Pro-only simple string-valued link meta (sanitise_text_field applied).
     *
     * @var list<string>
     */
    private const SIMPLE_META_KEYS = [
        'prettybar_template',
    ];

    /**
     * V3-canonical meta keys for delay (seconds, integer) and head scripts
     * (raw HTML/JS). Handled separately: delay needs int coercion; head
     * scripts must not be sanitised (they intentionally contain HTML/script).
     */
    private const META_DELAY        = 'delay';
    private const META_HEAD_SCRIPTS = 'head_scripts';

    /**
     * V3-canonical QR logo overrides. Each is optional on a link; absent
     * means "fall back to site-wide default in prlipro_options".
     */
    private const META_QR_LOGO_ID       = 'qr_logo_attachment_id';
    private const META_QR_LOGO_SIZE     = 'qr_logo_size_percent';
    private const META_QR_LOGO_WHITE_BG = 'qr_logo_white_background';

    /**
     * Links repository.
     *
     * @var LinksRepo
     */
    private $links;

    /**
     * Link metas repository.
     *
     * @var LinkMetas
     */
    private $metas;

    /**
     * Rotations repository.
     *
     * @var RotationsRepository
     */
    private $rotations;

    /**
     * Targeting rule evaluator.
     *
     * @var TargetingEvaluator
     */
    private $targetingEvaluator;

    /**
     * Constructor.
     *
     * @param LinksRepo           $links              Links repository.
     * @param LinkMetas           $metas              Link metas repository.
     * @param RotationsRepository $rotations          Rotations repository.
     * @param TargetingEvaluator  $targetingEvaluator Targeting rule evaluator.
     */
    public function __construct(
        LinksRepo $links,
        LinkMetas $metas,
        RotationsRepository $rotations,
        TargetingEvaluator $targetingEvaluator
    ) {
        $this->links              = $links;
        $this->metas              = $metas;
        $this->rotations          = $rotations;
        $this->targetingEvaluator = $targetingEvaluator;
    }

    /**
     * Register the Pro link-payload filters and actions.
     *
     * @return void
     */
    public function register(): void
    {
        add_filter('prli_link_payload_pre_save', [$this, 'stripPayload'], 10, 3);
        add_action('prli_link_after_save', [$this, 'persistPayload'], 10, 3);
        add_filter('prli_link_response_attach', [$this, 'attachSingle'], 10, 2);
        add_filter('prli_link_response_attach_bulk', [$this, 'attachBulk']);
        add_filter('prli_link_bulk_action', [$this, 'handleBulkAction'], 10, 4);
    }

    /**
     * Strip Pro-only keys from the link payload before the core save.
     *
     * @param  array<string, mixed> $data    Incoming link payload.
     * @param  string               $context Save context.
     * @param  integer              $linkId  Link id being saved.
     * @return array<string, mixed>
     */
    public function stripPayload(array $data, string $context, int $linkId): array
    {
        unset($context, $linkId);
        foreach (self::SIMPLE_META_KEYS as $key) {
            unset($data[$key]);
        }
        unset(
            $data['categories'],
            $data['tags'],
            $data['keywords'],
            $data['urlReplacements'],
            $data['splitTest'],
            $data['expiration'],
            $data['rotations'],
            $data['targeting'],
            $data['healthCheckExclude'],
            $data[self::META_DELAY],
            $data[self::META_HEAD_SCRIPTS],
            $data[self::META_QR_LOGO_ID],
            $data[self::META_QR_LOGO_SIZE],
            $data[self::META_QR_LOGO_WHITE_BG]
        );
        return $data;
    }

    /**
     * Persist Pro-only link payload data after the core save.
     *
     * @param integer              $linkId   Link id being saved.
     * @param array<string, mixed> $original Original (pre-strip) payload.
     * @param string               $context  Save context.
     *
     * @return void
     */
    public function persistPayload(int $linkId, array $original, string $context): void
    {
        unset($context);

        // Simple string-valued meta (prettybar_template).
        foreach (self::SIMPLE_META_KEYS as $key) {
            if (!array_key_exists($key, $original)) {
                continue;
            }
            $value = sanitize_text_field((string) $original[$key]);
            if ($value === '') {
                $this->metas->delete($linkId, $key);
            } else {
                $this->metas->set($linkId, $key, $value);
            }
        }

        // Taxonomies (categories / tags). Accept either bare ints (`[1, 2, 3]`)
        // or the `{id, name}` object shape returned by the REST read endpoints
        // — the frontend can round-trip the value it received without having
        // to remap to bare ids first.
        if (array_key_exists('categories', $original)) {
            $this->links->setTerms($linkId, 'category', self::coerceTermIds($original['categories']));
        }
        if (array_key_exists('tags', $original)) {
            $this->links->setTerms($linkId, 'tag', self::coerceTermIds($original['tags']));
        }

        // Keywords — handed off to the replacements module's own action.
        // Case is preserved on storage so the site-wide "Case-sensitive
        // matching" option can actually work — the regex flag is chosen
        // at match time, not flattened at write time. Dedup is still
        // applied case-sensitively here (identical-case duplicates are
        // always meaningless); case-variant entries like "Widget" and
        // "widget" are kept distinct and behave the way the match mode
        // dictates.
        if (array_key_exists('keywords', $original)) {
            $raw = $original['keywords'];
            if (is_string($raw)) {
                $raw = array_map('trim', explode(',', $raw));
            }
            $keywords = [];
            if (is_array($raw)) {
                foreach ($raw as $t) {
                    $t = trim((string) $t);
                    if ($t !== '' && !in_array($t, $keywords, true)) {
                        $keywords[] = $t;
                    }
                }
            }
            /**
             * Action: prli_link_save_keywords
             *
             * Pro's Replacements module listens, calls
             * KeywordRepository::setForLink, then invalidates the keyword
             * cache.
             *
             * @param int      $linkId
             * @param string[] $keywords
             */
            do_action('prli_link_save_keywords', $linkId, $keywords);
        }

        // URL Replacements (the per-link "replace these URLs with this
        // pretty link" list — v3's `prli-url-replacements` meta). Accepts
        // string (comma-separated, v3 admin form shape) or array.
        if (array_key_exists('urlReplacements', $original)) {
            $raw = $original['urlReplacements'];
            if (is_string($raw)) {
                $raw = array_map('trim', explode(',', $raw));
            }
            $urls = [];
            if (is_array($raw)) {
                foreach ($raw as $u) {
                    $u = esc_url_raw(trim((string) $u));
                    if ($u !== '' && !in_array($u, $urls, true)) {
                        $urls[] = $u;
                    }
                }
            }
            $previous = $this->getUrlReplacements($linkId);
            $this->setUrlReplacements($linkId, $urls);

            /**
             * Fires after the per-link URL replacement list is written.
             * Pro's PostUrlCache listens and invalidates the post URL
             * cache for any post containing any URL in the union of the
             * previous and new lists — only those posts may change
             * replacement behavior.
             *
             * @param int      $linkId
             * @param string[] $urls     New URL list (already normalized).
             * @param string[] $previous Prior URL list (for diff-aware invalidation).
             */
            do_action('prli_link_save_url_replacements', $linkId, $urls, $previous);
        }

        // Split-test `{enabled, goalLinkId}` object.
        if (array_key_exists('splitTest', $original)) {
            $raw = $original['splitTest'];
            if (is_array($raw)) {
                $enabled = !empty($raw['enabled']);
                if ($enabled) {
                    $this->metas->set($linkId, self::META_SPLIT_TEST_ENABLED, '1');
                } else {
                    $this->metas->delete($linkId, self::META_SPLIT_TEST_ENABLED);
                }
                $goalRaw = $raw['goalLinkId'] ?? null;
                if ($goalRaw !== null && $goalRaw !== '' && (int) $goalRaw > 0) {
                    $this->metas->set($linkId, self::META_SPLIT_TEST_GOAL, (string) (int) $goalRaw);
                } else {
                    $this->metas->delete($linkId, self::META_SPLIT_TEST_GOAL);
                }
            }
        }

        // Per-link "exclude from health checks" opt-out, plus a cleanup of any
        // stored health result once a link can no longer be meaningfully
        // checked — opted out here, or on a pixel / Stripe pay redirect type
        // (which have no probe-able target). Clearing the metas drops the link
        // off the broken badge, filter, and digest immediately; the health
        // read queries all key on `health_status = 'inactive'`, so no other
        // surface needs touching.
        $excluded = false;
        if (array_key_exists('healthCheckExclude', $original)) {
            $excluded = !empty($original['healthCheckExclude']);
            if ($excluded) {
                $this->metas->set($linkId, HealthChecker::META_EXCLUDE, '1');
            } else {
                $this->metas->delete($linkId, HealthChecker::META_EXCLUDE);
            }
        }
        // Prefer the payload's redirect_type, but fall back to the persisted
        // row when a partial save omits it — otherwise switching to
        // pixel/pay-link through a field-limited update wouldn't clear the
        // health record until the next runCheck() sweep.
        $redirectType = array_key_exists('redirect_type', $original)
            ? (string) $original['redirect_type']
            : (string) (($this->links->find($linkId)['redirect_type'] ?? ''));
        if ($excluded || in_array($redirectType, HealthChecker::UNCHECKABLE_REDIRECT_TYPES, true)) {
            foreach (HealthChecker::HEALTH_META_KEYS as $healthKey) {
                $this->metas->delete($linkId, $healthKey);
            }
        }

        // Delay (seconds; 0 = disabled). v3-canonical `delay` meta key.
        if (array_key_exists(self::META_DELAY, $original)) {
            $delay = max(0, (int) $original[self::META_DELAY]);
            if ($delay > 0) {
                $this->metas->set($linkId, self::META_DELAY, (string) $delay);
            } else {
                $this->metas->delete($linkId, self::META_DELAY);
            }
        }

        // Head scripts (raw HTML/JS, unsanitised by design — same as v3).
        if (array_key_exists(self::META_HEAD_SCRIPTS, $original)) {
            $scripts = (string) $original[self::META_HEAD_SCRIPTS];
            if ($scripts !== '') {
                $this->metas->set($linkId, self::META_HEAD_SCRIPTS, $scripts);
            } else {
                $this->metas->delete($linkId, self::META_HEAD_SCRIPTS);
            }
        }

        // QR logo override (attachment id). 0 = inherit from site default.
        if (array_key_exists(self::META_QR_LOGO_ID, $original)) {
            $logoId = max(0, (int) $original[self::META_QR_LOGO_ID]);
            if ($logoId > 0) {
                $this->metas->set($linkId, self::META_QR_LOGO_ID, (string) $logoId);
            } else {
                $this->metas->delete($linkId, self::META_QR_LOGO_ID);
            }
        }

        // QR logo size override. Null/0 = inherit. Range matched to
        // LogoOverlay's internal clamp (10–40).
        if (array_key_exists(self::META_QR_LOGO_SIZE, $original)) {
            $size = (int) $original[self::META_QR_LOGO_SIZE];
            if ($size > 0) {
                $this->metas->set($linkId, self::META_QR_LOGO_SIZE, (string) max(10, min(30, $size)));
            } else {
                $this->metas->delete($linkId, self::META_QR_LOGO_SIZE);
            }
        }

        // QR white background override. Tri-state: '1' = on, '0' = off,
        // absent = inherit from site default. Client passes null when the
        // admin hasn't explicitly toggled it on this link.
        if (array_key_exists(self::META_QR_LOGO_WHITE_BG, $original)) {
            $raw = $original[self::META_QR_LOGO_WHITE_BG];
            if ($raw === null || $raw === '') {
                $this->metas->delete($linkId, self::META_QR_LOGO_WHITE_BG);
            } else {
                $this->metas->set($linkId, self::META_QR_LOGO_WHITE_BG, $raw ? '1' : '0');
            }
        }

        // Expiration `{enabled, type, date, clicks, override_enabled, override_url}`.
        if (array_key_exists('expiration', $original) && is_array($original['expiration'])) {
            $this->persistExpiration($linkId, $original['expiration']);
        }

        // Rotations `{mode, entries: [{url, weight, id?}]}`.
        if (array_key_exists('rotations', $original) && is_array($original['rotations'])) {
            $this->persistRotations($linkId, $original['rotations']);
        }

        // Targeting `{mode, rules: [{operator, value, redirect_to}]}`.
        if (array_key_exists('targeting', $original) && is_array($original['targeting'])) {
            $this->persistTargeting($linkId, $original['targeting']);
        }
    }

    /**
     * Replace the URL Replacements list for a link. v3 stored this as
     * multiple rows in `prli_link_metas` under meta_key
     * `prli-url-replacements`, one per URL. LinkMetas::set enforces
     * one-row-per-(link,key) so we write directly.
     *
     * @param integer  $linkId Link id to write replacements for.
     * @param string[] $urls   Already-normalized, deduped URL list.
     */
    private function setUrlReplacements(int $linkId, array $urls): void
    {
        global $wpdb;
        $table = $wpdb->prefix . 'prli_link_metas';
        $wpdb->delete($table, [
            'link_id'  => $linkId,
            'meta_key' => self::META_URL_REPLACEMENTS,
        ]);
        if (!$urls) {
            return;
        }
        $now = current_time('mysql', true);
        foreach ($urls as $i => $url) {
            $wpdb->insert($table, [
                'link_id'    => $linkId,
                'meta_key'   => self::META_URL_REPLACEMENTS,
                'meta_value' => $url,
                'meta_order' => (int) $i,
                'created_at' => $now,
            ]);
        }
    }

    /**
     * Read the URL Replacements list for a link.
     *
     * @param  integer $linkId Link id to read replacements for.
     * @return string[] Ordered list (by `meta_order`, then id) of URL
     * replacement entries for the link. v3 drop-in.
     */
    private function getUrlReplacements(int $linkId): array
    {
        global $wpdb;
        $table = $wpdb->prefix . 'prli_link_metas';
        $rows  = (array) $wpdb->get_col(
            $wpdb->prepare(
                "SELECT meta_value FROM {$table}
                 WHERE link_id = %d AND meta_key = %s
                 ORDER BY meta_order ASC, id ASC",
                $linkId,
                self::META_URL_REPLACEMENTS
            )
        );
        return array_map('strval', $rows);
    }

    /**
     * Persist the v3-compatible expiration meta keys.
     *
     * @param integer              $linkId Link id to write expiration for.
     * @param array<string, mixed> $body   Submitted expiration settings.
     */
    private function persistExpiration(int $linkId, array $body): void
    {
        $enabled = !empty($body['enabled']);
        $this->metas->set($linkId, 'enable_expire', $enabled ? '1' : '0');

        if (! $enabled) {
            // Keep configured values so re-enabling is friction-free (v3 parity).
            return;
        }

        $type = isset($body['type']) && $body['type'] === 'clicks' ? 'clicks' : 'date';
        $this->metas->set($linkId, 'expire_type', $type);

        if ($type === 'date') {
            $date = isset($body['date']) ? sanitize_text_field((string) $body['date']) : '';
            if ($date !== '') {
                $this->metas->set($linkId, 'expire_date', $date);
            } else {
                $this->metas->delete($linkId, 'expire_date');
            }
            $this->metas->delete($linkId, 'expire_clicks');
        } else {
            $clicks = isset($body['clicks']) ? max(0, (int) $body['clicks']) : 0;
            $this->metas->set($linkId, 'expire_clicks', (string) $clicks);
            $this->metas->delete($linkId, 'expire_date');
        }

        // `action` (v4): `redirect` (default) or `trash`. This field is
        // authoritative — the legacy `override_enabled` toggle is gone from
        // the v4 form, so redirect + a valid URL alone drives the redirect.
        $action = isset($body['action']) && $body['action'] === 'trash' ? 'trash' : 'redirect';
        $this->metas->set($linkId, 'expire_action', $action);

        $overrideUrl = '';
        if (isset($body['override_url']) && preg_match('%^https?://%i', (string) $body['override_url'])) {
            $overrideUrl = esc_url_raw((string) $body['override_url']);
        }
        // V3-compat flag: on rollback, v3 redirects when this is '1' and 404s
        // otherwise — so `trash` (a v4-only action) must leave it '0'.
        $redirects = $action === 'redirect' && $overrideUrl !== '';
        $this->metas->set($linkId, 'enable_expired_url', $redirects ? '1' : '0');
        if ($overrideUrl !== '') {
            $this->metas->set($linkId, 'expired_url', $overrideUrl);
        } else {
            $this->metas->delete($linkId, 'expired_url');
        }
    }

    /**
     * Persist rotation entries + the distribution mode meta.
     *
     * @param integer              $linkId Link id to write rotations for.
     * @param array<string, mixed> $body   Submitted rotation settings.
     */
    private function persistRotations(int $linkId, array $body): void
    {
        $entries = [];
        if (isset($body['entries']) && is_array($body['entries'])) {
            foreach ($body['entries'] as $entry) {
                if (!is_array($entry)) {
                    continue;
                }
                $entries[] = [
                    'id'     => isset($entry['id']) ? (int) $entry['id'] : 0,
                    'url'    => isset($entry['url']) ? (preg_match('%^https?://%i', (string) $entry['url']) ? esc_url_raw((string) $entry['url']) : '') : '',
                    'weight' => isset($entry['weight']) ? max(1, (int) $entry['weight']) : 1,
                ];
            }
        }
        $this->rotations->replaceAll($linkId, $entries);

        $mode = isset($body['mode']) && $body['mode'] === 'sequential' ? 'sequential' : 'weighted';
        $this->metas->set($linkId, 'rotation_mode', $mode);

        // The link's own Target URL is the primary rotation participant; its
        // weight lives in the `prli-target-url-weight` meta (v3-compatible),
        // not in the rotations table (which holds only the extra URLs).
        $primaryWeight = isset($body['primaryWeight']) ? max(1, (int) $body['primaryWeight']) : 100;
        $this->metas->set($linkId, 'prli-target-url-weight', (string) $primaryWeight);
    }

    /**
     * Persist dynamic-redirection mode + targeting rules.
     *
     * @param integer              $linkId Link id to write targeting for.
     * @param array<string, mixed> $body   Submitted targeting settings.
     */
    private function persistTargeting(int $linkId, array $body): void
    {
        $mode = null;
        if (array_key_exists('mode', $body)) {
            $mode = (string) $body['mode'];
            if (!in_array($mode, [DynamicMode::NONE, DynamicMode::ROTATE, DynamicMode::GEO, DynamicMode::TECH, DynamicMode::TIME], true)) {
                $mode = DynamicMode::NONE;
            }
            $this->metas->set($linkId, DynamicMode::META_KEY, $mode);
        }

        if (array_key_exists('rules', $body)) {
            $raw   = is_array($body['rules']) ? $body['rules'] : [];
            $clean = [];
            foreach ($raw as $rule) {
                if (!is_array($rule)) {
                    continue;
                }
                $clean[] = [
                    'operator'    => isset($rule['operator']) ? (string) $rule['operator'] : '',
                    'value'       => $rule['value'] ?? null,
                    'redirect_to' => isset($rule['redirect_to']) ? (preg_match('%^https?://%i', (string) $rule['redirect_to']) ? esc_url_raw((string) $rule['redirect_to']) : '') : '',
                ];
            }
            if ($clean === []) {
                $this->metas->delete($linkId, 'targeting_rules');

                // Clearing every rule while still in a geo/tech/time mode would
                // leave loadTargeting's empty-rules fallback free to re-surface
                // migrated v3 legacy metas on the next read. Reset the mode to
                // NONE so a cleared link stays cleared.
                $effectiveMode = $mode !== null ? $mode : DynamicMode::forLink($linkId);
                if (DynamicMode::allowsTargeting($effectiveMode)) {
                    $this->metas->set($linkId, DynamicMode::META_KEY, DynamicMode::NONE);
                }
            } else {
                $this->metas->set($linkId, 'targeting_rules', (string) wp_json_encode($clean));
            }
        }
    }

    /**
     * Attach Pro-only fields to a single link REST response.
     *
     * @param  array<string, mixed> $link   Link response payload.
     * @param  integer              $linkId Link id being read.
     * @return array<string, mixed>
     */
    public function attachSingle(array $link, int $linkId): array
    {
        foreach (self::SIMPLE_META_KEYS as $key) {
            $link[$key] = (string) $this->metas->get($linkId, $key, '');
        }

        $link[self::META_DELAY]        = (int) ($this->metas->get($linkId, self::META_DELAY, '0') ?: 0);
        $link[self::META_HEAD_SCRIPTS] = (string) $this->metas->get($linkId, self::META_HEAD_SCRIPTS, '');

        $link[self::META_QR_LOGO_ID]       = (int) ($this->metas->get($linkId, self::META_QR_LOGO_ID, '0') ?: 0);
        $link[self::META_QR_LOGO_SIZE]     = (int) ($this->metas->get($linkId, self::META_QR_LOGO_SIZE, '0') ?: 0);
        $qrWhiteBg                         = $this->metas->get($linkId, self::META_QR_LOGO_WHITE_BG);
        $link[self::META_QR_LOGO_WHITE_BG] = $qrWhiteBg === null ? null : ($qrWhiteBg === '1');

        // Taxonomies come from the same bulk filter the list endpoint uses,
        // so the wire shape is `[{id, name}, ...]` on every read endpoint.
        // (The historical divergence — bare ints here, objects in index —
        // forced the frontend to branch per endpoint).
        $terms              = (array) apply_filters('prli_link_load_terms_bulk', [], [$linkId]);
        $linkTerms          = $terms[$linkId] ?? [
            'categories' => [],
            'tags'       => [],
        ];
        $link['categories'] = $linkTerms['categories'] ?? [];
        $link['tags']       = $linkTerms['tags'] ?? [];

        /**
         * Filter: prli_link_load_keywords
         *
         * Pro's Replacements\KeywordRepository listens and returns the
         * list of keyword strings attached to this link.
         *
         * @param string[] $keywords Default empty list.
         * @param int      $linkId
         */
        $link['keywords']        = (array) apply_filters('prli_link_load_keywords', [], $linkId);
        $link['urlReplacements'] = $this->getUrlReplacements($linkId);

        $goalId            = (int) $this->metas->get($linkId, self::META_SPLIT_TEST_GOAL);
        $link['splitTest'] = [
            'enabled'    => $this->metas->get($linkId, self::META_SPLIT_TEST_ENABLED) === '1',
            'goalLinkId' => $goalId > 0 ? $goalId : null,
        ];

        $link['expiration'] = $this->loadExpiration($linkId);
        $link['rotations']  = $this->loadRotations($linkId);
        $link['targeting']  = $this->loadTargeting($linkId);

        $link['healthCheckExclude'] = $this->metas->get($linkId, HealthChecker::META_EXCLUDE, '') === '1';

        return $link;
    }

    /**
     * Load the expiration settings for a link.
     *
     * @param  integer $linkId Link id to read expiration for.
     * @return array<string, mixed>
     */
    private function loadExpiration(int $linkId): array
    {
        $enabled = $this->metas->get($linkId, 'enable_expire', '') === '1';
        $type    = (string) $this->metas->get($linkId, 'expire_type', 'date');
        if ($type !== 'clicks') {
            $type = 'date';
        }
        $date = (string) $this->metas->get($linkId, 'expire_date', '');
        // Surface the redirect URL only when it's actually active
        // (enable_expired_url = '1'). A migrated v3 link that 404s but still
        // has a stored expired_url (enable flag off) must NOT read back as an
        // active redirect — otherwise a later save would flip it 404→redirect.
        $redirectOn = $this->metas->get($linkId, 'enable_expired_url', '') === '1';
        return [
            'enabled'      => $enabled,
            'type'         => $type,
            'date'         => $date,
            'clicks'       => (int) ($this->metas->get($linkId, 'expire_clicks', '0') ?: 0),
            'action'       => $this->metas->get($linkId, 'expire_action', '') === 'trash' ? 'trash' : 'redirect',
            'override_url' => $redirectOn ? (string) $this->metas->get($linkId, 'expired_url', '') : '',
        ];
    }

    /**
     * Load the rotation settings for a link.
     *
     * @param  integer $linkId Link id to read rotations for.
     * @return array<string, mixed>
     */
    private function loadRotations(int $linkId): array
    {
        $mode = (string) $this->metas->get($linkId, 'rotation_mode', 'weighted');
        if ($mode !== 'sequential') {
            $mode = 'weighted';
        }
        $primaryWeight = (int) $this->metas->get($linkId, 'prli-target-url-weight', '100');
        if ($primaryWeight < 1) {
            $primaryWeight = 100;
        }
        return [
            'mode'          => $mode,
            'primaryWeight' => $primaryWeight,
            'entries'       => $this->rotations->forLink($linkId),
        ];
    }

    /**
     * Load the targeting settings for a link.
     *
     * @param  integer $linkId Link id to read targeting for.
     * @return array<string, mixed>
     */
    private function loadTargeting(int $linkId): array
    {
        $raw   = (string) $this->metas->get($linkId, 'targeting_rules', '');
        $list  = $raw !== '' ? json_decode($raw, true) : [];
        $rules = is_array($list) ? array_values($list) : [];
        $mode  = DynamicMode::forLink($linkId);

        // V3 drop-in parity: when there's no v4 `targeting_rules` yet but the
        // link is in a geo/tech/time mode with legacy metas, surface the same
        // rules the runtime Evaluator adapts on the fly. Without this a
        // migrated link's working rules render as an empty editor (#637).
        // Read-only adaptation — nothing is written, so the legacy rows stay
        // intact for a clean rollback to v3.
        if ($rules === [] && DynamicMode::allowsTargeting($mode)) {
            $rules = $this->targetingEvaluator->legacyRulesFor($linkId, $mode);
        }

        return [
            'mode'       => $mode,
            'rules'      => $rules,
            'active_now' => $mode === DynamicMode::TIME
                ? $this->targetingEvaluator->hasActiveTimeRule($linkId)
                : false,
        ];
    }

    /**
     * Bulk-enrich the Links list rows. Batched to keep N+1 at bay:
     *  - taxonomies via `prli_link_load_terms_bulk`
     *  - keywords   via `prli_link_load_keywords_bulk`
     *  - split-test via one metas SELECT
     *
     * @param  array<int, array<string, mixed>> $items Link list rows to enrich.
     * @return array<int, array<string, mixed>>
     */
    public function attachBulk(array $items): array
    {
        if (!$items) {
            return $items;
        }
        $ids = array_map(static fn ($row) => (int) $row['id'], $items);

        /**
         * Filter: prli_link_load_terms_bulk
         *
         * Returns a map of link id => {categories, tags} each a list of
         * `{id, name}` rows for the Links list columns.
         *
         * @param array<int, array{categories: array<int, array{id:int,name:string}>, tags: array<int, array{id:int,name:string}>}> $default
         * @param int[] $ids
         */
        $terms = (array) apply_filters('prli_link_load_terms_bulk', [], $ids);

        /**
         * Filter: prli_link_load_keywords_bulk
         *
         * Returns a map of link id => string[] of keyword texts.
         *
         * @param array<int, string[]> $default Empty map.
         * @param int[]                $ids
         */
        $keywordsByLink = (array) apply_filters('prli_link_load_keywords_bulk', [], $ids);

        $splitMeta      = $this->loadSplitTestMetaBulk($ids);
        $healthMeta     = $this->loadHealthMetaBulk($ids);
        $targetingMeta  = $this->loadTargetingBulk($ids);
        $expirationMeta = $this->loadExpirationBulk($ids);

        foreach ($items as &$row) {
            $linkId              = (int) $row['id'];
            $rowTerms            = $terms[$linkId] ?? [
                'categories' => [],
                'tags'       => [],
            ];
            $row['categories']   = $rowTerms['categories'];
            $row['tags']         = $rowTerms['tags'];
            $rawKeywords         = $keywordsByLink[$linkId] ?? [];
            $row['keywords']     = is_array($rawKeywords) ? array_values(array_map('strval', $rawKeywords)) : [];
            $row['splitTest']    = $splitMeta[$linkId] ?? [
                'enabled'    => false,
                'goalLinkId' => null,
            ];
            $row['healthStatus'] = $healthMeta[$linkId] ?? null;
            $row['targeting']    = $targetingMeta[$linkId] ?? [
                'mode'       => DynamicMode::NONE,
                'rules'      => [],
                'active_now' => false,
            ];
            $row['expiration']   = $expirationMeta[$linkId] ?? [
                'enabled'      => false,
                'type'         => 'date',
                'date'         => '',
                'clicks'       => 0,
                'action'       => 'redirect',
                'override_url' => '',
            ];
        }
        unset($row);

        return $items;
    }

    /**
     * Handle Pro bulk actions on the Links list.
     *
     * @param  array<string, mixed> $result  Result accumulator from prior handlers.
     * @param  string               $action  Bulk action slug.
     * @param  int[]                $ids     Link ids the action applies to.
     * @param  int[]                $termIds Term ids to add/remove.
     * @return array<string, mixed>
     */
    public function handleBulkAction(array $result, string $action, array $ids, array $termIds): array
    {
        $proActions = [
            'bulk_add_categories',
            'bulk_remove_categories',
            'bulk_add_tags',
            'bulk_remove_tags',
        ];
        if (!in_array($action, $proActions, true)) {
            return $result;
        }
        return (array) $this->links->bulk($action, $ids, $termIds);
    }

    /**
     * Batch-load `{mode, rules, active_now}` per link. Mirrors the per-link
     * semantics in `DynamicMode::forLink()`: explicit meta wins; otherwise
     * "rotate if rotations exist, else none".
     *
     * `active_now` is true when the link's mode is TIME and at least one
     * `date_between` rule matches the current request context. Computed
     * per TIME-mode link via the targeting Evaluator — cheap in practice
     * because only TIME-mode rows pay the evaluation cost.
     *
     * @param  int[] $linkIds Link ids to load targeting for.
     * @return array<int, array{mode: string, rules: array<int, mixed>, active_now: bool}>
     */
    private function loadTargetingBulk(array $linkIds): array
    {
        if (!$linkIds) {
            return [];
        }
        global $wpdb;
        $table        = $wpdb->prefix . 'prli_link_metas';
        $placeholders = implode(',', array_fill(0, count($linkIds), '%d'));

        // Explicit mode meta for the rows that have one.
        $modeRows = (array) $wpdb->get_results(
            $wpdb->prepare(
                "SELECT link_id, meta_value FROM {$table}
                 WHERE link_id IN ({$placeholders}) AND meta_key = %s",
                array_merge($linkIds, [DynamicMode::META_KEY])
            ),
            ARRAY_A
        );
        $modeMap  = [];
        foreach ($modeRows as $r) {
            $modeMap[(int) $r['link_id']] = (string) $r['meta_value'];
        }

        // Links that have at least one rotation row — drives the "empty
        // meta falls back to rotate" v3 behaviour.
        $rotationsTable  = $wpdb->prefix . 'prli_link_rotations';
        $rotationLinkIds = (array) $wpdb->get_col(
            $wpdb->prepare(
                "SELECT DISTINCT link_id FROM {$rotationsTable}
                 WHERE link_id IN ({$placeholders})",
                $linkIds
            )
        );
        $hasRotations    = array_fill_keys(array_map('intval', $rotationLinkIds), true);

        // Rules JSON for the rows that have one.
        $rulesRows = (array) $wpdb->get_results(
            $wpdb->prepare(
                "SELECT link_id, meta_value FROM {$table}
                 WHERE link_id IN ({$placeholders}) AND meta_key = %s",
                array_merge($linkIds, ['targeting_rules'])
            ),
            ARRAY_A
        );
        $rulesMap  = [];
        foreach ($rulesRows as $r) {
            $decoded                       = json_decode((string) $r['meta_value'], true);
            $rulesMap[(int) $r['link_id']] = is_array($decoded) ? array_values($decoded) : [];
        }

        $out = [];
        foreach ($linkIds as $id) {
            $id       = (int) $id;
            $explicit = $modeMap[$id] ?? '';
            if ($explicit !== '') {
                $mode = $explicit;
            } elseif (isset($hasRotations[$id])) {
                $mode = DynamicMode::ROTATE;
            } else {
                $mode = DynamicMode::NONE;
            }
            $out[$id] = [
                'mode'       => $mode,
                'rules'      => $rulesMap[$id] ?? [],
                'active_now' => $mode === DynamicMode::TIME
                    ? $this->targetingEvaluator->hasActiveTimeRule($id)
                    : false,
            ];
        }
        return $out;
    }

    /**
     * Batch-load expiration state per link in a single metas query.
     *
     * @param  int[] $linkIds Link ids to load expiration for.
     * @return array<int, array{enabled: bool, type: string, date: string, clicks: int, override_enabled: bool, override_url: string}>
     */
    private function loadExpirationBulk(array $linkIds): array
    {
        if (!$linkIds) {
            return [];
        }
        global $wpdb;
        $table           = $wpdb->prefix . 'prli_link_metas';
        $placeholders    = implode(',', array_fill(0, count($linkIds), '%d'));
        $keys            = [
            'enable_expire',
            'expire_type',
            'expire_date',
            'expire_clicks',
            'expire_action',
            'enable_expired_url',
            'expired_url',
        ];
        $keyPlaceholders = implode(',', array_fill(0, count($keys), '%s'));
        $rows            = (array) $wpdb->get_results(
            $wpdb->prepare(
                "SELECT link_id, meta_key, meta_value FROM {$table}
                 WHERE link_id IN ({$placeholders})
                   AND meta_key IN ({$keyPlaceholders})",
                array_merge($linkIds, $keys)
            ),
            ARRAY_A
        );

        // Pivot to [linkId => [metaKey => metaValue]].
        $byLink = [];
        foreach ($rows as $r) {
            $byLink[(int) $r['link_id']][(string) $r['meta_key']] = (string) $r['meta_value'];
        }

        $out = [];
        foreach ($linkIds as $id) {
            $id    = (int) $id;
            $metas = $byLink[$id] ?? [];
            $type  = $metas['expire_type'] ?? 'date';
            if ($type !== 'clicks') {
                $type = 'date';
            }
            $date       = $metas['expire_date'] ?? '';
            $enabled    = ($metas['enable_expire'] ?? '') === '1';
            $redirectOn = ($metas['enable_expired_url'] ?? '') === '1';
            $out[$id]   = [
                'enabled'      => $enabled,
                'type'         => $type,
                'date'         => $date,
                'clicks'       => (int) ($metas['expire_clicks'] ?? 0),
                'action'       => ($metas['expire_action'] ?? '') === 'trash' ? 'trash' : 'redirect',
                'override_url' => $redirectOn ? (string) ($metas['expired_url'] ?? '') : '',
            ];
        }
        return $out;
    }

    /**
     * Batch-load health meta for all link IDs. One query for all three keys.
     * `health_status` ('active'/'inactive') is canonical; `health_status_code`
     * is the additive v4 HTTP-code meta surfaced in the digest email only.
     *
     * @param  int[] $linkIds Link ids to load health meta for.
     * @return array<int, array{code: int|null, lastChecked: string|null, status: string}|null>
     */
    private function loadHealthMetaBulk(array $linkIds): array
    {
        if (!$linkIds) {
            return [];
        }
        global $wpdb;
        $table        = $wpdb->prefix . 'prli_link_metas';
        $placeholders = implode(',', array_fill(0, count($linkIds), '%d'));
        $params       = array_merge(
            $linkIds,
            ['health_status', 'health_status_code', 'health_last_checked']
        );
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
        $sql = "SELECT link_id, meta_key, meta_value
                   FROM {$table}
                  WHERE link_id IN ({$placeholders})
                    AND meta_key IN (%s, %s, %s)";
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $rows = (array) $wpdb->get_results($wpdb->prepare($sql, ...$params), ARRAY_A);

        $byLink = [];
        foreach ($rows as $row) {
            $id    = (int) $row['link_id'];
            $key   = (string) $row['meta_key'];
            $value = (string) ($row['meta_value'] ?? '');
            if (!isset($byLink[$id])) {
                $byLink[$id] = [
                    'code'        => null,
                    'lastChecked' => null,
                    'raw'         => null,
                ];
            }
            if ($key === 'health_status_code') {
                $byLink[$id]['code'] = (int) $value;
            } elseif ($key === 'health_last_checked') {
                $byLink[$id]['lastChecked'] = $value;
            } elseif ($key === 'health_status') {
                $byLink[$id]['raw'] = $value;
            }
        }

        foreach ($byLink as $id => &$data) {
            $raw            = $data['raw'];
            $data['status'] = $raw === 'active'
                ? 'ok'
                : ($raw === 'inactive' ? 'broken' : 'unchecked');
            unset($data['raw']);
        }
        unset($data);

        return $byLink;
    }

    /**
     * Batch-load split-test meta per link.
     *
     * @param  int[] $linkIds Link ids to load split-test meta for.
     * @return array<int, array{enabled: bool, goalLinkId: ?int}>
     */
    private function loadSplitTestMetaBulk(array $linkIds): array
    {
        if (!$linkIds) {
            return [];
        }
        global $wpdb;
        $table        = $wpdb->prefix . 'prli_link_metas';
        $placeholders = implode(',', array_fill(0, count($linkIds), '%d'));
        $params       = array_merge(
            $linkIds,
            [self::META_SPLIT_TEST_ENABLED, self::META_SPLIT_TEST_GOAL]
        );
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
        $sql = "SELECT link_id, meta_key, meta_value
                   FROM {$table}
                  WHERE link_id IN ({$placeholders})
                    AND meta_key IN (%s, %s)";
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $rows = (array) $wpdb->get_results($wpdb->prepare($sql, ...$params), ARRAY_A);

        $byLink = [];
        foreach ($linkIds as $id) {
            $byLink[(int) $id] = [
                'enabled'    => false,
                'goalLinkId' => null,
            ];
        }
        foreach ($rows as $row) {
            $id    = (int) $row['link_id'];
            $key   = (string) $row['meta_key'];
            $value = (string) ($row['meta_value'] ?? '');
            if ($key === self::META_SPLIT_TEST_ENABLED) {
                $byLink[$id]['enabled'] = $value !== '' && $value !== '0';
            } elseif ($key === self::META_SPLIT_TEST_GOAL) {
                $goalId                    = (int) $value;
                $byLink[$id]['goalLinkId'] = $goalId > 0 ? $goalId : null;
            }
        }
        return $byLink;
    }

    /**
     * Normalise a `categories`/`tags` payload to a list of positive ints.
     *
     * Accepts either the bare-int shape (`[1, 2, 3]`) or the `{id, name}`
     * object shape (the read endpoints return the latter so the frontend
     * can submit back the value it received without remapping).
     *
     * @param  mixed $raw Raw categories/tags payload value.
     * @return int[]
     */
    private static function coerceTermIds($raw): array
    {
        if (!is_array($raw)) {
            return [];
        }
        $out = [];
        foreach ($raw as $entry) {
            if (is_array($entry) && isset($entry['id'])) {
                $id = (int) $entry['id'];
            } elseif (is_object($entry) && isset($entry->id)) {
                $id = (int) $entry->id;
            } else {
                $id = (int) $entry;
            }
            if ($id > 0) {
                $out[] = $id;
            }
        }
        return array_values(array_unique($out));
    }
}
