<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Health;

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// Batch/cleanup queries build their placeholder list dynamically (variable
// count of meta keys / redirect types) and bind through a single spread
// array_merge(); the replacement count is correct but not statically countable.
// 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\Options\Store as OptionsStore;

/**
 * Runs the link-health check. Probes every non-deleted link (HEAD, falling
 * back to GET when HEAD errors or returns >= 400) and stores the result in
 * `prli_link_metas` under:
 *
 *   - `health_status`        (v3-canonical) `'active'` | `'inactive'`
 *   - `health_last_checked`  (shared v3/v4) datetime string
 *   - `health_status_code`   (v4-additive)  HTTP code as int string; 0 if
 *                            the probe errored. Not used for broken/ok
 *                            decisions — only surfaced in the digest email.
 *   - `health_fail_count`    (v4-additive)  consecutive failing probes; see
 *                            FAIL_THRESHOLD.
 *
 * `health_status` is the source of truth. Keeping the v3 shape means a
 * rollback to v3 reads the same rows without translation.
 *
 * ## What counts as "broken"
 *
 * A probe is only broken when its HTTP code is in the
 * `prli_invalid_response_codes` list (v3 hook; default {@see
 * self::DEFAULT_INVALID_CODES}). Soft blocks (403/405/429/…) and transport
 * errors are treated as reachable — a datacenter probe against a
 * bot-protected host routinely gets those even for a perfectly good link
 * (#790). On top of that, a link must fail {@see self::FAIL_THRESHOLD}
 * consecutive probes before it flips to `inactive`, so a single blip never
 * fires a false alarm. A link left in that unconfirmed-failure state is
 * re-probed ~24h later ({@see self::RETRY_WINDOW_SECONDS}) — via a scheduled
 * retry tick from {@see self::runCheck()} — rather than waiting for the next
 * weekly/monthly window, so a genuine breakage is confirmed within a day.
 * Links opted out via {@see self::META_EXCLUDE} or using an
 * {@see self::UNCHECKABLE_REDIRECT_TYPES} redirect type are never probed at
 * all.
 *
 * ## Concurrency model
 *
 * `runCheck()` processes one batch of {@see self::BATCH_SIZE} stale links
 * per invocation, then self-chains via `wp_schedule_single_event` so the
 * remaining queue drains without blocking any single request past a
 * reasonable duration. A transient lock prevents overlapping runs — if a
 * chained batch fires while the previous is still probing, the second
 * bails and is re-tried by the next recurring/chained event.
 *
 * A link is considered "stale" when its `health_last_checked` meta is
 * absent or older than the current frequency window (with a small margin
 * so chained batches can finish before the recurring event fires again).
 * This keeps a mid-run recurring tick from re-probing already-fresh links.
 */
class HealthChecker
{
    public const META_LAST_CHECKED = 'health_last_checked';
    public const META_STATUS       = 'health_status';
    public const META_STATUS_CODE  = 'health_status_code';

    /**
     * Consecutive-failure counter (v4-additive). Incremented on every probe
     * that returns a "broken" code and reset to 0 on any healthy probe. A
     * link only flips to `inactive` once this reaches FAIL_THRESHOLD — see
     * {@see self::resolveStatus()}.
     */
    public const META_FAIL_COUNT = 'health_fail_count';

    /**
     * Per-link opt-out (v4-additive). `'1'` means "never health-check this
     * link" — the batch runner skips it and its health metas are cleared so
     * it drops off the broken badge, filter, and digest. Any other/absent
     * value means "check normally".
     */
    public const META_EXCLUDE = 'health_check_exclude';

    /**
     * Every meta key the health checker owns for a link. Single source of
     * truth for "clear this link's health record" so callers don't hand-list
     * the keys (and drift when one is added).
     *
     * @var list<string>
     */
    public const HEALTH_META_KEYS = [
        self::META_STATUS,
        self::META_STATUS_CODE,
        self::META_LAST_CHECKED,
        self::META_FAIL_COUNT,
    ];

    /**
     * Response codes that mark a link broken, absent a
     * `prli_invalid_response_codes` override.
     *
     * Version 3 defaulted to `['404']` and treated everything else — 403,
     * 405, 429, 5xx, and transport errors — as reachable. We keep that
     * conservative stance (410 Gone added: an explicit, permanent "this
     * resource is dead" that 404 doesn't always convey) because affiliate
     * networks and large retailers behind bot protection answer a
     * server-side probe with 403/405/429 (or a bot-masking 404) even though
     * the link loads fine in a browser. Treating those as broken is the
     * false positive reported in #790.
     *
     * @var list<string>
     */
    public const DEFAULT_INVALID_CODES = ['404', '410'];

    /**
     * Consecutive failing probes required before a link is flagged broken.
     * A single blocked/flapping probe leaves the link "pending" (prior
     * status untouched) rather than firing a false alarm. Override via the
     * `prli_link_health_fail_threshold` filter.
     */
    public const FAIL_THRESHOLD = 2;

    /**
     * How long after a failing-but-unconfirmed probe to re-check the link,
     * rather than waiting for the full weekly/monthly staleness window. Keeps
     * confirmation (and, once confirmed, the digest) inside a day of the first
     * failure instead of a whole cycle later. Override via the
     * `prli_link_health_retry_window` filter.
     */
    public const RETRY_WINDOW_SECONDS = DAY_IN_SECONDS;

    /**
     * Redirect types with no meaningful target to probe. Tracking pixels
     * return an empty body and Stripe pay-links resolve to a checkout flow —
     * both would read as "broken" against any status-code rule. v3 excluded
     * these from the health scan; we match that.
     *
     * @var list<string>
     */
    public const UNCHECKABLE_REDIRECT_TYPES = ['pixel', 'prettypay_link_stripe'];

    /**
     * Browser-like User-Agent for probes. A bare/unknown agent is a common
     * trigger for 403/429 bot blocks that would otherwise read as "broken".
     */
    public const USER_AGENT = 'Mozilla/5.0 (compatible; PrettyLinksHealth/4.0; +https://prettylinks.com)';

    /**
     * Per-batch link cap. Worst case a link costs a HEAD plus a GET fallback
     * at the 10s timeout each, so 50 × ~20s ≈ 17 min — see LOCK_TTL.
     */
    public const BATCH_SIZE = 50;

    /**
     * Transient key used to serialize overlapping runs.
     */
    public const LOCK_KEY = 'prli_link_health_running';

    /**
     * Transient that throttles the ineligible-metas sweep to at most once an
     * hour, so a long chained drain doesn't re-run it every batch.
     */
    public const SWEEP_THROTTLE_KEY = 'prli_link_health_swept';

    /**
     * Run-lock TTL. Must outlast one batch's worst case (BATCH_SIZE links each
     * timing out on both the HEAD and the GET fallback, ≈17 min) so the lock
     * doesn't expire mid-run and let a chained/recurring tick overlap.
     */
    private const LOCK_TTL = 25 * MINUTE_IN_SECONDS;

    /**
     * Transient that is the single source of truth for "a forced pass is in
     * progress". "Force check now" sets it to a `Y-m-d H:i:s` cutoff (now);
     * while present it replaces the staleness window so every link is
     * re-checked, and it's deleted once the pass drains. Every tick — the
     * immediate one, chained ones, and the recurring one — reads it, so the
     * pass is driven by this one value rather than a cron argument that could
     * go stale. Its TTL is refreshed on every chained batch, so it lives for
     * as long as the pass keeps draining; the fixed value below is only a
     * backstop for a pass that dies between batches.
     */
    public const FORCE_CUTOFF_KEY = 'prli_link_health_force_before';

    /**
     * TTL for FORCE_CUTOFF_KEY, refreshed each chained batch. Long enough that
     * a pass which crashes between batches still clears on its own.
     */
    public const FORCE_CUTOFF_TTL = 6 * HOUR_IN_SECONDS;

    /**
     * Self-chain delay. Longer than 0 so WP-Cron doesn't fire immediately.
     */
    private const CHAIN_DELAY = 60;

    /**
     * Drive one batch of the health check. Safe to call repeatedly; the run
     * lock serializes overlapping calls. Whether this batch runs "forced"
     * (re-check everything) or "normal" (staleness window only) is decided
     * solely by the FORCE_CUTOFF_KEY transient — no cron argument — so a tick
     * can never disagree with the actual pass state.
     */
    public function runCheck(): void
    {
        // Hard enable-gate. Even though Scheduler clears both hooks when the
        // feature is off, an ad-hoc single event (chain / retry / force) can
        // still be sitting in the cron table when it's disabled — this guard
        // makes such an orphan a no-op that neither probes nor reschedules,
        // and sweeps any siblings so they don't keep firing.
        if (!$this->isEnabled()) {
            wp_clear_scheduled_hook(Scheduler::ADHOC_HOOK);
            // Drop any in-flight force cutoff too, so re-enabling within its
            // TTL doesn't resume a stranded forced full-rescan.
            delete_transient(self::FORCE_CUTOFF_KEY);
            return;
        }

        // The transient is the sole force signal: present → a forced pass is
        // in progress and its (frozen) cutoff drives selection; absent → the
        // normal frequency-based staleness window.
        $forceBefore = (string) get_transient(self::FORCE_CUTOFF_KEY);

        if (get_transient(self::LOCK_KEY)) {
            // Another run holds the lock. If a forced pass is pending, requeue
            // so a free tick continues it rather than dropping the user's
            // click. When not forced there's nothing to preserve — the next
            // recurring tick suffices. A requeued tick that arrives after the
            // pass already drained simply finds no cutoff and runs a cheap
            // normal batch (no spurious full re-scan).
            if ($forceBefore !== '') {
                wp_schedule_single_event(time() + self::CHAIN_DELAY, Scheduler::ADHOC_HOOK);
            }
            return;
        }
        set_transient(self::LOCK_KEY, (string) time(), self::LOCK_TTL);

        try {
            // Enforce the invariant "opted-out / unprobe-able links hold no
            // health record". Batch selection already skips these links, so
            // without this a stale `inactive` row — left by an import, a bulk
            // edit, or the pre-#790 >=400 logic — would linger in the
            // badge/filter/digest with no way to clear. Throttled to once an
            // hour (via a transient) so a long forced drain of chained batches
            // doesn't re-run the JOIN every 60s on a large meta table; the
            // transitions it heals arrive far slower than that.
            if (!get_transient(self::SWEEP_THROTTLE_KEY)) {
                $this->clearIneligibleHealthMetas();
                set_transient(self::SWEEP_THROTTLE_KEY, '1', HOUR_IN_SECONDS);
            }
            $batch = $this->processBatch($forceBefore);
        } finally {
            delete_transient(self::LOCK_KEY);
        }
        $processed = $batch['processed'];

        // Any link left failing-but-unconfirmed gets a re-check ~24h out
        // instead of waiting for the next weekly/monthly window, so a real
        // breakage is confirmed (and, once confirmed, emailed) within a day.
        // Uses the ad-hoc hook (not the recurring one) so a frequency change
        // can't clear it and it can't mask the recurrence. The CHAIN_DELAY
        // buffer past the window guarantees the retry fires strictly after the
        // pending link's last_checked clears `retryBefore` (the selection uses
        // a strict `<`), so a same-second tick can't skip the link. WP-Cron
        // dedupes near-identical events, so repeated pending batches in one
        // pass won't pile up retries.
        if ($batch['pending'] > 0) {
            wp_schedule_single_event(time() + $this->retryWindowSeconds() + self::CHAIN_DELAY, Scheduler::ADHOC_HOOK);
        }

        if ($processed >= self::BATCH_SIZE) {
            // More links to do — chain. The next tick re-reads the transient,
            // so it stays forced while the cutoff is set and reverts to the
            // staleness window once the pass drains.
            if ($forceBefore !== '') {
                // Refresh the cutoff's TTL so a long forced drain (many chained
                // batches on a large catalog) can't outlive it and silently
                // fall back to the staleness window mid-pass. Re-read the
                // current value rather than writing back this batch's own
                // $forceBefore: a newer "Force check now" may have landed
                // mid-batch, and re-setting the stale value would clobber it
                // and make later batches use the old cutoff.
                $current = (string) get_transient(self::FORCE_CUTOFF_KEY);
                if ($current !== '') {
                    set_transient(self::FORCE_CUTOFF_KEY, $current, self::FORCE_CUTOFF_TTL);
                }
            }
            wp_schedule_single_event(time() + self::CHAIN_DELAY, Scheduler::ADHOC_HOOK);
        } elseif ($forceBefore !== '' && (string) get_transient(self::FORCE_CUTOFF_KEY) === $forceBefore) {
            // Forced pass drained — clear the cutoff so later ticks go back to
            // the normal staleness window. Guard on the value so a newer "Force
            // check now" that landed while this batch finalized isn't wiped.
            delete_transient(self::FORCE_CUTOFF_KEY);
        }
    }

    /**
     * Check a single link and return its health result.
     *
     * @param  integer $linkId Link ID to probe.
     * @return array{code: int, lastChecked: string, status: string}
     */
    public function checkLink(int $linkId): array
    {
        global $wpdb;
        $row = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT url, redirect_type FROM {$wpdb->prefix}prli_links WHERE id = %d AND deleted_at IS NULL LIMIT 1",
                $linkId
            ),
            ARRAY_A
        );

        $now          = gmdate('Y-m-d H:i:s');
        $url          = (string) ($row['url'] ?? '');
        $redirectType = (string) ($row['redirect_type'] ?? '');

        if ($url === '') {
            return [
                'code'        => 0,
                'lastChecked' => $now,
                'status'      => 'broken',
            ];
        }

        // Opt-out or unprobe-able redirect type: never record a health
        // result. Clear any stale metas so the link drops off the broken
        // badge/filter/digest immediately, then report it as unchecked.
        if ($this->isExcluded($linkId) || $this->isUncheckableType($redirectType)) {
            $this->clearHealthMetas($linkId);
            return [
                'code'        => 0,
                'lastChecked' => $now,
                'status'      => 'unchecked',
            ];
        }

        $code     = $this->probe($url);
        $resolved = $this->resolveStatus($linkId, $code);
        $this->writeHealthMetas($linkId, $now, $code, $resolved['status'], $resolved['failCount']);

        return [
            'code'        => $code,
            'lastChecked' => $now,
            'status'      => $resolved['display'],
        ];
    }

    /**
     * Probes one batch of stale links and writes their health metas.
     *
     * @param  string $forceBefore Optional `Y-m-d H:i:s` cutoff (from a
     *                            user-initiated force check). When set it
     *                            replaces the frequency-based staleness window,
     *                            so a link is re-probed unless it was already
     *                            checked at/after that instant.
     * @return array{processed: int, pending: int} Links probed this batch, and
     *                            how many were left in an unconfirmed-failure
     *                            ("pending") state — the retry driver.
     */
    private function processBatch(string $forceBefore = ''): array
    {
        global $wpdb;
        $forced      = $forceBefore !== '';
        $staleBefore = $forced
            ? $forceBefore
            : gmdate('Y-m-d H:i:s', time() - $this->staleWindowSeconds());
        // A link with an unconfirmed failure streak is re-probed on this
        // shorter window rather than the full weekly/monthly one, so a real
        // breakage is confirmed within a day. During a forced pass the main
        // cutoff already selects everything, so this is a no-op then.
        $retryBefore = gmdate('Y-m-d H:i:s', time() - $this->retryWindowSeconds());

        // Skip opted-out links and unprobe-able redirect types (pixel / Stripe
        // pay-links have no meaningful target — v3 parity). Both use a
        // NOT-IN / NOT-IN subquery so a link with the exclude meta or such a
        // redirect type never enters a batch.
        $typePlaceholders = implode(',', array_fill(0, count(self::UNCHECKABLE_REDIRECT_TYPES), '%s'));

        // A link is due when it's never been probed, its last probe is older
        // than the full staleness window, OR it has a pending failure
        // (fail_count set and not yet confirmed inactive) older than the short
        // retry window. `fc`/`st` join the fail-count and status metas for the
        // retry clause.
        //
        // Oldest-stale-first so a partial run converges; un-probed links come
        // first so a brand-new install populates the initial set before
        // re-probing anything.
        $params = array_merge(
            [self::META_LAST_CHECKED, self::META_FAIL_COUNT, self::META_STATUS],
            self::UNCHECKABLE_REDIRECT_TYPES,
            [self::META_EXCLUDE, $staleBefore, $retryBefore, self::BATCH_SIZE]
        );
        $sql    = $wpdb->prepare(
            "SELECT l.id, l.url, m.meta_value AS last_checked
               FROM {$wpdb->prefix}prli_links l
          LEFT JOIN {$wpdb->prefix}prli_link_metas m
                 ON m.link_id = l.id AND m.meta_key = %s
          LEFT JOIN {$wpdb->prefix}prli_link_metas fc
                 ON fc.link_id = l.id AND fc.meta_key = %s
          LEFT JOIN {$wpdb->prefix}prli_link_metas st
                 ON st.link_id = l.id AND st.meta_key = %s
              WHERE l.deleted_at IS NULL
                AND l.url <> ''
                AND l.redirect_type NOT IN ({$typePlaceholders})
                AND l.id NOT IN (
                    SELECT link_id FROM {$wpdb->prefix}prli_link_metas
                    WHERE meta_key = %s AND meta_value = '1'
                )
                AND (
                    m.meta_value IS NULL
                    OR m.meta_value < %s
                    OR (
                        fc.meta_value IS NOT NULL AND fc.meta_value <> '0'
                        AND (st.meta_value IS NULL OR st.meta_value <> 'inactive')
                        AND m.meta_value < %s
                    )
                )
           ORDER BY CASE WHEN m.meta_value IS NULL THEN 0 ELSE 1 END,
                    m.meta_value ASC,
                    l.id ASC
              LIMIT %d",
            ...$params
        );
        $rows   = $wpdb->get_results($sql, ARRAY_A) ?: [];
        if ($rows === []) {
            return [
                'processed' => 0,
                'pending'   => 0,
            ];
        }

        $now     = gmdate('Y-m-d H:i:s');
        $pending = 0;
        foreach ($rows as $row) {
            $linkId   = (int) $row['id'];
            $code     = $this->probe((string) $row['url']);
            $resolved = $this->resolveStatus($linkId, $code);
            $this->writeHealthMetas($linkId, $now, $code, $resolved['status'], $resolved['failCount']);
            // A null status means "failed but not yet confirmed broken" — the
            // case the 24h retry exists to re-check.
            if ($resolved['status'] === null) {
                ++$pending;
            }
        }
        return [
            'processed' => count($rows),
            'pending'   => $pending,
        ];
    }

    /**
     * Whether the Link Health feature is switched on. Read straight from the
     * Pro options row so an orphaned cron tick can gate on it without the
     * scheduler being involved.
     *
     * @return boolean
     */
    private function isEnabled(): bool
    {
        $options = (array) get_option(OptionsStore::OPTION, []);
        return !empty($options['enable_link_health']);
    }

    /**
     * Window (in seconds) during which a probe result is considered fresh.
     * Shorter than the cron cadence by a margin so chained batches finish
     * before the next recurring tick fires.
     */
    private function staleWindowSeconds(): int
    {
        $options   = (array) get_option(OptionsStore::OPTION, []);
        $frequency = isset($options['link_health_frequency'])
            ? (string) $options['link_health_frequency']
            : 'weekly';
        return $frequency === 'monthly' ? 28 * DAY_IN_SECONDS : 6 * DAY_IN_SECONDS;
    }

    /**
     * Window (in seconds) after a failing-but-unconfirmed probe before the
     * link is re-checked. Clamped to a floor of 1 minute so a filter can't
     * schedule an immediate hot loop.
     *
     * @return integer
     */
    private function retryWindowSeconds(): int
    {
        /**
         * Filter: prli_link_health_retry_window
         *
         * @param int $seconds Default {@see self::RETRY_WINDOW_SECONDS}.
         */
        $seconds = (int) apply_filters('prli_link_health_retry_window', self::RETRY_WINDOW_SECONDS);
        return max(MINUTE_IN_SECONDS, $seconds);
    }

    /**
     * Probes a URL and returns its HTTP status code (0 on transport error).
     *
     * Tries HEAD first (cheap), then falls back to GET when HEAD errors or
     * returns >= 400. Many servers and CDNs (Cloudflare, security plugins,
     * some WooCommerce/shop pages) reject or mishandle HEAD — answering 405,
     * 403, 429, or 501 — while a normal GET returns 200. Without the fallback
     * those links are flagged "broken" even though they load fine in a
     * browser (the false positives reported in #878964). A browser-like
     * User-Agent avoids trivial bot blocks, and a slightly higher redirect
     * cap tolerates http->https->www->trailing-slash chains.
     *
     * @param  string $url URL to probe.
     * @return integer HTTP status code, or 0 when the request errored.
     */
    private function probe(string $url): int
    {
        $args = [
            'timeout'     => 10,
            'redirection' => 5,
            'user-agent'  => self::USER_AGENT,
        ];

        $response = wp_safe_remote_head($url, $args);
        $code     = is_wp_error($response) ? 0 : (int) wp_remote_retrieve_response_code($response);

        if ($code === 0 || $code >= 400) {
            // Only pull the headers back, not a full body, to keep it cheap.
            $getResponse = wp_safe_remote_get($url, $args + ['limit_response_size' => 4096]);
            $getCode     = is_wp_error($getResponse) ? 0 : (int) wp_remote_retrieve_response_code($getResponse);
            if ($getCode > 0) {
                $code = $getCode;
            }
        }

        return $code;
    }

    /**
     * Decides a link's health status from a fresh probe code, applying the
     * invalid-code list and the consecutive-failure threshold.
     *
     * Returns the value to persist as `health_status` (or null to leave the
     * stored status untouched — a "pending" failure that hasn't yet been
     * confirmed), the new consecutive-failure count, and a display status
     * ('ok' | 'broken' | 'unchecked') for the REST response.
     *
     * @param  integer $linkId Link being probed (for its prior counters).
     * @param  integer $code   HTTP status code from the probe (0 = transport error).
     * @return array{status: string|null, failCount: int, display: string}
     */
    private function resolveStatus(int $linkId, int $code): array
    {
        if (!$this->isBrokenCode($code)) {
            // Reachable (2xx/3xx, a soft block like 403/405/429, or a
            // transport error we won't hold against the link). Clear the
            // failure streak and mark active.
            return [
                'status'    => 'active',
                'failCount' => 0,
                'display'   => 'ok',
            ];
        }

        // Store the true running count (not the capped threshold) so raising
        // the threshold filter mid-streak stays coherent.
        $count = (int) $this->getMeta($linkId, self::META_FAIL_COUNT) + 1;
        if ($count >= $this->failThreshold()) {
            return [
                'status'    => 'inactive',
                'failCount' => $count,
                'display'   => 'broken',
            ];
        }

        // Not enough consecutive failures to call it broken yet. Leave the
        // stored status alone (status => null) so we neither raise a false
        // alarm nor paint a just-failed link green. The display status
        // mirrors whatever the link was before this blip.
        $prev = (string) $this->getMeta($linkId, self::META_STATUS);
        return [
            'status'    => null,
            'failCount' => $count,
            'display'   => $prev === 'active' ? 'ok' : ($prev === 'inactive' ? 'broken' : 'unchecked'),
        ];
    }

    /**
     * Whether a probe response code marks the link broken. Backed by the
     * v3-compatible `prli_invalid_response_codes` filter.
     *
     * @param  integer $code HTTP status code (0 = transport error).
     * @return boolean
     */
    private function isBrokenCode(int $code): bool
    {
        /**
         * Filter: prli_invalid_response_codes
         *
         * HTTP status codes that mark a link's target as broken. v3 default
         * was `['404']`; v4 adds `'410'`. Values are compared as strings.
         *
         * @param string[] $codes Default {@see self::DEFAULT_INVALID_CODES}.
         */
        $codes = apply_filters('prli_invalid_response_codes', self::DEFAULT_INVALID_CODES);
        $codes = array_map('strval', is_array($codes) ? $codes : self::DEFAULT_INVALID_CODES);
        return in_array((string) $code, $codes, true);
    }

    /**
     * Consecutive failing probes required before flagging a link broken.
     * Clamped to a floor of 1 so a filter can't disable confirmation into
     * an always-false state.
     *
     * @return integer
     */
    private function failThreshold(): int
    {
        /**
         * Filter: prli_link_health_fail_threshold
         *
         * @param int $threshold Default {@see self::FAIL_THRESHOLD}.
         */
        $threshold = (int) apply_filters('prli_link_health_fail_threshold', self::FAIL_THRESHOLD);
        return max(1, $threshold);
    }

    /**
     * Whether a link is opted out of health checks via META_EXCLUDE.
     *
     * @param  integer $linkId Link ID to test.
     * @return boolean
     */
    private function isExcluded(int $linkId): bool
    {
        return (string) $this->getMeta($linkId, self::META_EXCLUDE) === '1';
    }

    /**
     * Whether a redirect type has no meaningful target to probe.
     *
     * @param  string $redirectType The link's redirect_type column.
     * @return boolean
     */
    private function isUncheckableType(string $redirectType): bool
    {
        return in_array($redirectType, self::UNCHECKABLE_REDIRECT_TYPES, true);
    }

    /**
     * Persists the health metas for a link.
     *
     * @param  integer     $linkId    Link ID to write metas for.
     * @param  string      $now       Datetime string of this check.
     * @param  integer     $code      HTTP status code from the probe.
     * @param  string|null $status    Canonical health status ('active' or
     *                                'inactive'), or null to leave the stored
     *                                status untouched (unconfirmed failure).
     * @param  integer     $failCount Consecutive-failure count to store.
     * @return void
     */
    private function writeHealthMetas(int $linkId, string $now, int $code, ?string $status, int $failCount): void
    {
        $this->putMeta($linkId, self::META_LAST_CHECKED, $now);
        $this->putMeta($linkId, self::META_STATUS_CODE, (string) $code);
        $this->putMeta($linkId, self::META_FAIL_COUNT, (string) $failCount);
        if ($status !== null) {
            $this->putMeta($linkId, self::META_STATUS, $status);
        }
    }

    /**
     * Removes every health meta for a link. Used when a link is opted out or
     * switches to an unprobe-able redirect type, so it drops off the broken
     * badge/filter/digest at once.
     *
     * @param  integer $linkId Link ID to clear.
     * @return void
     */
    public function clearHealthMetas(int $linkId): void
    {
        global $wpdb;
        $table        = $wpdb->prefix . 'prli_link_metas';
        $placeholders = implode(',', array_fill(0, count(self::HEALTH_META_KEYS), '%s'));
        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$table} WHERE link_id = %d AND meta_key IN ({$placeholders})",
                ...array_merge([$linkId], self::HEALTH_META_KEYS)
            )
        );
    }

    /**
     * Deletes health metas for every link that should not carry one — opted
     * out via META_EXCLUDE, or using an unprobe-able redirect type. Keeps the
     * broken badge/filter/digest honest for links that reached that state
     * outside the edit form (import, bulk edit, legacy data), which batch
     * selection alone would leave with a stale record.
     *
     * @return void
     */
    private function clearIneligibleHealthMetas(): void
    {
        global $wpdb;
        $metas            = $wpdb->prefix . 'prli_link_metas';
        $links            = $wpdb->prefix . 'prli_links';
        $keyPlaceholders  = implode(',', array_fill(0, count(self::HEALTH_META_KEYS), '%s'));
        $typePlaceholders = implode(',', array_fill(0, count(self::UNCHECKABLE_REDIRECT_TYPES), '%s'));

        $wpdb->query(
            $wpdb->prepare(
                "DELETE m FROM {$metas} m
                   JOIN {$links} l ON l.id = m.link_id
                  WHERE m.meta_key IN ({$keyPlaceholders})
                    AND (
                        l.redirect_type IN ({$typePlaceholders})
                        OR m.link_id IN (
                            SELECT link_id FROM (
                                SELECT link_id FROM {$metas}
                                 WHERE meta_key = %s AND meta_value = '1'
                            ) x
                        )
                    )",
                ...array_merge(
                    self::HEALTH_META_KEYS,
                    self::UNCHECKABLE_REDIRECT_TYPES,
                    [self::META_EXCLUDE]
                )
            )
        );
    }

    /**
     * Reads a single link meta value ('' when absent).
     *
     * @param  integer $linkId Link ID the meta belongs to.
     * @param  string  $key    Meta key to read.
     * @return string
     */
    private function getMeta(int $linkId, string $key): string
    {
        global $wpdb;
        $table = $wpdb->prefix . 'prli_link_metas';
        return (string) ($wpdb->get_var(
            $wpdb->prepare(
                "SELECT meta_value FROM {$table} WHERE link_id = %d AND meta_key = %s LIMIT 1",
                $linkId,
                $key
            )
        ) ?? '');
    }

    /**
     * Upserts a single link meta row.
     *
     * @param  integer $linkId Link ID the meta belongs to.
     * @param  string  $key    Meta key to write.
     * @param  string  $value  Meta value to store.
     * @return void
     */
    private function putMeta(int $linkId, string $key, string $value): void
    {
        global $wpdb;
        $table = $wpdb->prefix . 'prli_link_metas';

        $existing = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT id FROM {$table} WHERE link_id = %d AND meta_key = %s LIMIT 1",
                $linkId,
                $key
            )
        );
        if ($existing) {
            $wpdb->update(
                $table,
                ['meta_value' => $value],
                ['id' => (int) $existing]
            );
            return;
        }
        $wpdb->insert(
            $table,
            [
                'link_id'    => $linkId,
                'meta_key'   => $key,
                'meta_value' => $value,
                'created_at' => current_time('mysql', true),
            ]
        );
    }
}
