<?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.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_status` is the source of truth. Keeping the v3 shape means a
 * rollback to v3 reads the same rows without translation.
 *
 * ## 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';

    /**
     * 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';

    /**
     * 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
    {
        // 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::HOOK);
            }
            return;
        }
        set_transient(self::LOCK_KEY, (string) time(), self::LOCK_TTL);

        try {
            $processed = $this->processBatch($forceBefore);
        } finally {
            delete_transient(self::LOCK_KEY);
        }

        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::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;
        $url = (string) ($wpdb->get_var(
            $wpdb->prepare(
                "SELECT url FROM {$wpdb->prefix}prli_links WHERE id = %d AND deleted_at IS NULL LIMIT 1",
                $linkId
            )
        ) ?? '');

        $now = gmdate('Y-m-d H:i:s');

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

        $code   = $this->probe($url);
        $status = $code > 0 && $code < 400 ? 'active' : 'inactive';
        $this->writeHealthMetas($linkId, $now, $code, $status);

        return [
            'code'        => $code,
            'lastChecked' => $now,
            'status'      => $status === 'active' ? 'ok' : 'broken',
        ];
    }

    /**
     * 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 integer Links probed in this batch.
     */
    private function processBatch(string $forceBefore = ''): int
    {
        global $wpdb;
        $staleBefore = $forceBefore !== ''
            ? $forceBefore
            : gmdate('Y-m-d H:i:s', time() - $this->staleWindowSeconds());

        // 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.
        $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
              WHERE l.deleted_at IS NULL
                AND l.url <> ''
                AND (m.meta_value IS NULL OR 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",
            self::META_LAST_CHECKED,
            $staleBefore,
            self::BATCH_SIZE
        );
        $rows = $wpdb->get_results($sql, ARRAY_A) ?: [];
        if ($rows === []) {
            return 0;
        }

        $now = gmdate('Y-m-d H:i:s');
        foreach ($rows as $row) {
            $code   = $this->probe((string) $row['url']);
            $status = $code > 0 && $code < 400 ? 'active' : 'inactive';
            $this->writeHealthMetas((int) $row['id'], $now, $code, $status);
        }
        return count($rows);
    }

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

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

    /**
     * Persists the three 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  $status Canonical health status ('active' or 'inactive').
     * @return void
     */
    private function writeHealthMetas(int $linkId, string $now, int $code, string $status): void
    {
        $this->putMeta($linkId, self::META_LAST_CHECKED, $now);
        $this->putMeta($linkId, self::META_STATUS, $status);
        $this->putMeta($linkId, self::META_STATUS_CODE, (string) $code);
    }

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