<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\PrettyBar;

/**
 * Detects whether a target URL permits iframe embedding.
 *
 * Performs a HEAD request and inspects `X-Frame-Options` and CSP
 * `frame-ancestors`. If the target genuinely refuses framing, callers should
 * silently fall back to a plain 302 redirect per REWRITE-PLAN §7.2.
 *
 * This is deliberately a coarse heuristic, not a browser-grade CSP engine. A
 * HEAD from our WP server is a different request, from a different origin, than
 * the visitor's browser makes, so it can only reliably recognise the headers
 * that forbid framing for EVERY origin: `X-Frame-Options: DENY` and CSP
 * `frame-ancestors 'none'` (or an empty list). Everything else is treated as
 * frameable — v3 shipped with no preflight at all and always rendered the cloak
 * iframe. Over-blocking is exactly what surfaced as "the target shows in the
 * address bar" after upgrading from v3 (HS #878927), most often on sites whose
 * security plugin injects `X-Frame-Options: SAMEORIGIN` site-wide. As a small
 * nicety we still downgrade a cross-origin `SAMEORIGIN` target to a clean 302
 * (a frame the browser would refuse anyway).
 */
class FrameDetector
{
    /**
     * Transient key prefix for cached framing verdicts.
     */
    private const CACHE_PREFIX = 'prli_frame_';

    /**
     * Default cache lifetime for a framing verdict (seconds).
     */
    private const DEFAULT_CACHE_TTL = DAY_IN_SECONDS;

    /**
     * Determines whether the given URL permits being embedded in an iframe.
     *
     * Results are cached per destination URL and pretty-link parent origin so
     * repeat cloak/Pretty Bar hits do not pay a blocking HEAD on every click
     * (#741). Only the first hit within the TTL probes; later hits reuse the
     * stored verdict.
     *
     * @param string $url The target URL to inspect.
     *
     * @return boolean True if framing appears to be allowed.
     */
    public function allowsFraming(string $url): bool
    {
        if ($url === '') {
            return false;
        }

        $cacheKey = $this->cacheKey($url);
        $ttl      = $this->cacheTtl();
        if ($ttl !== null) {
            $cached = get_transient($cacheKey);
            if ($cached === '1' || $cached === '0') {
                return $cached === '1';
            }
        } else {
            // Caching disabled — drop any prior verdict so re-enabling TTL does
            // not serve a stale transient from before the filter was applied.
            delete_transient($cacheKey);
        }

        $verdict = $this->probeAllowsFraming($url);
        // Transport failures fail open but are not cached — the next hit should
        // retry in case the destination was temporarily unreachable.
        if ($verdict !== null && $ttl !== null) {
            set_transient($cacheKey, $verdict ? '1' : '0', $ttl);
        }

        return $verdict ?? true;
    }

    /**
     * Issues the HEAD probe and evaluates framing headers.
     *
     * @param string $url Target URL.
     *
     * @return boolean|null True/false when the probe returned a definitive
     *                      verdict; null when the transport failed (inconclusive).
     */
    private function probeAllowsFraming(string $url): ?bool
    {
        $response = wp_safe_remote_head($url, [
            'timeout'     => 5,
            'redirection' => 2,
            'user-agent'  => 'Pretty Links Bar/4.0',
        ]);

        // Fail open. Many servers/CDNs/security plugins reject or throttle HEAD
        // or a bare user-agent, and slow targets can exceed the short timeout.
        // v3 framed unconditionally, so a probe we can't complete must not be
        // treated as a refusal — only an explicit deny header should block.
        if (is_wp_error($response)) {
            return null;
        }

        // CSP frame-ancestors takes precedence: when present, browsers evaluate
        // framing against it and IGNORE X-Frame-Options.
        $csp = $this->headerString(wp_remote_retrieve_header($response, 'content-security-policy'));
        $cspDecision = $this->frameAncestorsDecision($csp);
        if ($cspDecision !== null) {
            return $cspDecision;
        }

        // No frame-ancestors — fall back to X-Frame-Options. Match on
        // whitespace/comma-separated tokens rather than a substring so values
        // like "denied" don't spuriously block.
        $xfoTokens = preg_split('/[\s,]+/', strtolower($this->headerString(
            wp_remote_retrieve_header($response, 'x-frame-options')
        )), -1, PREG_SPLIT_NO_EMPTY) ?: [];

        // DENY forbids framing by anyone.
        if (in_array('deny', $xfoTokens, true)) {
            return false;
        }
        // SAMEORIGIN only forbids *cross-origin* framing. The cloak document is
        // served from this site, so a same-origin target frames fine; a
        // cross-origin one gets a clean 302 instead of a browser-refused frame.
        if (in_array('sameorigin', $xfoTokens, true) && !$this->isSameOrigin($url)) {
            return false;
        }

        return true;
    }

    /**
     * Builds a transient key for a destination URL.
     *
     * Includes the pretty-link parent origin because the SAMEORIGIN exemption
     * depends on it (alternate shortlink domain).
     *
     * @param string $url Target URL.
     *
     * @return string
     */
    private function cacheKey(string $url): string
    {
        return self::CACHE_PREFIX . md5(
            $this->normalizeCacheUrl($url) . '|' . $this->origin($this->parentBaseUrl())
        );
    }

    /**
     * Normalizes a destination URL for cache-key purposes so equivalent URLs
     * (default port, host casing) share one transient. Trailing slashes and
     * embedded userinfo are preserved — they can change probe behaviour.
     *
     * @param string $url Target URL.
     *
     * @return string
     */
    private function normalizeCacheUrl(string $url): string
    {
        $parts = wp_parse_url($url);
        if (!is_array($parts) || empty($parts['host'])) {
            return $url;
        }
        // Scheme-relative or scheme-less URLs resolve differently at probe time
        // than explicit https:// links — do not fold them into one cache entry.
        if (empty($parts['scheme'])) {
            return $url;
        }

        $auth = '';
        if (!empty($parts['user'])) {
            $auth = $parts['user'];
            if (isset($parts['pass'])) {
                $auth .= ':' . $parts['pass'];
            }
            $auth .= '@';
        }

        $path  = isset($parts['path']) ? (string) $parts['path'] : '';
        $query = isset($parts['query']) && $parts['query'] !== '' ? '?' . $parts['query'] : '';

        return $auth . $this->origin($url) . $path . $query;
    }

    /**
     * Cache lifetime for framing verdicts, or null when caching is disabled.
     *
     * Two filters gate caching, both funnelling into the same "off" result so
     * there is a single disable path:
     * - `prli_frame_detector_cache_enabled` — return `false` for a clear on/off
     *   switch (probe on every hit). This is the readable way to turn caching
     *   off, e.g. for links whose target is dynamic within the TTL window.
     * - `prli_frame_detector_cache_ttl` — controls the lifetime; `0` (or any
     *   value <= 0) also disables, positive values clamp to a 60s minimum.
     *
     * @return integer|null Seconds to cache, or null when caching is off.
     */
    private function cacheTtl(): ?int
    {
        if (function_exists('apply_filters') && !apply_filters('prli_frame_detector_cache_enabled', true)) {
            return null;
        }
        $ttl = self::DEFAULT_CACHE_TTL;
        if (function_exists('apply_filters')) {
            $ttl = (int) apply_filters('prli_frame_detector_cache_ttl', $ttl);
        }
        if ($ttl <= 0) {
            return null;
        }
        return max(60, $ttl);
    }

    /**
     * Evaluates a Content-Security-Policy header for `frame-ancestors`.
     *
     * @param string $csp Raw CSP header value (may hold several policies joined
     *                    by commas, or several directives joined by semicolons).
     *
     * @return boolean|null False when a `frame-ancestors` directive forbids
     *                      everyone (empty or `'none'`); true when one is present
     *                      but permissive (framing allowed, XFO ignored); null
     *                      when no `frame-ancestors` directive is present.
     */
    private function frameAncestorsDecision(string $csp): ?bool
    {
        $found = false;
        // Split on both `;` (directives) and `,` (stacked policies). We only
        // need to spot a directive that blocks everyone, so this coarse split
        // is sufficient without a full policy parser.
        foreach (preg_split('/[;,]/', $csp) ?: [] as $directive) {
            $directive = trim($directive);
            // Anchored, exact directive name so `frame-ancestors-foo` won't match.
            if (!preg_match('/^frame-ancestors(?:\s|$)/i', $directive)) {
                continue;
            }
            $found  = true;
            $tokens = preg_split('/\s+/', strtolower($directive), -1, PREG_SPLIT_NO_EMPTY) ?: [];
            array_shift($tokens); // Drop the directive name.
            // An empty list or 'none' forbids framing by everyone.
            if ($tokens === [] || in_array("'none'", $tokens, true)) {
                return false;
            }
        }

        return $found ? true : null;
    }

    /**
     * Normalizes a `wp_remote_retrieve_header()` return (string, or array when
     * a header appears more than once) into a single string.
     *
     * @param mixed $header Raw header value.
     *
     * @return string
     */
    private function headerString($header): string
    {
        if (is_array($header)) {
            return implode(', ', array_map('strval', $header));
        }
        return is_string($header) ? $header : '';
    }

    /**
     * Whether the URL shares scheme + host + port with the pretty-link base
     * (the cloak parent origin), not necessarily `home_url()`.
     *
     * @param string $url Target URL.
     *
     * @return boolean
     */
    private function isSameOrigin(string $url): bool
    {
        $site = $this->origin($this->parentBaseUrl());
        return $site !== '' && $this->origin($url) === $site;
    }

    /**
     * The scheme+host the pretty-link document is actually served from — the
     * origin the browser evaluates `X-Frame-Options` against. This is the
     * pretty-link base, NOT necessarily `home_url()`: Pro's alternate shortlink
     * domain renders pretty URLs on a different host via the
     * `prli_pretty_link_base` filter, and that host is the iframe's ancestor.
     *
     * @return string
     */
    private function parentBaseUrl(): string
    {
        if (!function_exists('home_url')) {
            return '';
        }
        if (function_exists('apply_filters')) {
            return (string) apply_filters('prli_pretty_link_base', home_url());
        }
        return (string) home_url();
    }

    /**
     * Reduces a URL to its origin (scheme://host[:port]), lower-cased.
     *
     * @param string $url URL to reduce.
     *
     * @return string Empty string when the URL has no host.
     */
    private function origin(string $url): string
    {
        if ($url === '') {
            return '';
        }
        $parts = wp_parse_url($url);
        if (!is_array($parts) || empty($parts['host'])) {
            return '';
        }
        $scheme = strtolower((string) ($parts['scheme'] ?? 'http'));
        $host   = strtolower((string) $parts['host']);

        // Normalize away the default port so https://host and https://host:443
        // (and http://host / http://host:80) compare as the same origin.
        $port = '';
        if (isset($parts['port'])) {
            $portNum   = (int) $parts['port'];
            $isDefault = ($scheme === 'https' && $portNum === 443)
                || ($scheme === 'http' && $portNum === 80);
            if (!$isDefault) {
                $port = ':' . $portNum;
            }
        }

        return $scheme . '://' . $host . $port;
    }
}
