<?php
/**
 * Tam Rank™ RSS feed optimisation
 *
 * Adds a line of text before and/or after the content of every item in the RSS
 * feed, with a link back to the original. Scrapers that republish the feed
 * verbatim then carry an attribution link to the source.
 *
 * Off by default — same principle as the WebP conversion setting.
 *
 * @package TamRank
 */

if (!defined('ABSPATH')) {
    exit;
}

class TamRank_RSS
{
    /** Option name holding the RSS settings. */
    const OPTION = 'tam_rank_rss_settings';

    public function __construct()
    {
        add_filter('the_content_feed', array($this, 'filter_content'), 10, 2);
        add_filter('the_excerpt_rss', array($this, 'filter_excerpt'), 10, 1);
    }

    /**
     * Default settings.
     *
     * @return array
     */
    public static function get_defaults()
    {
        return array(
            'enabled' => false,
            'before'  => '',
            'after'   => '',
        );
    }

    /**
     * Stored settings merged over the defaults.
     *
     * @return array
     */
    public static function get_settings()
    {
        $stored = function_exists('tam_rank_cached_option')
            ? tam_rank_cached_option(self::OPTION, array())
            : get_option(self::OPTION, array());

        if (!is_array($stored)) {
            $stored = array();
        }

        return wp_parse_args($stored, self::get_defaults());
    }

    /**
     * The placeholders a user may use in the before/after text.
     *
     * @return string[]
     */
    public static function get_placeholders()
    {
        return array('%POSTLINK%', '%SITELINK%', '%AUTHORLINK%', '%POSTTITLE%');
    }

    /* ------------------------------------------------------------------
     * Duplicate-feed-text detection
     *
     * Other SEO plugins add their own paragraph to every feed item through
     * the exact same two filters we use. With both switched on, every item
     * ends up carrying two lines. We only report that — we never read, write
     * or clear another plugin's settings.
     * ----------------------------------------------------------------*/

    /**
     * The other SEO plugins that put text in every feed item.
     *
     * One row per plugin: which plugin files count as "active", which option
     * holds the text, how that option is stored, and which fields to look at.
     *
     * `default_filled` says what the plugin itself falls back to when the key
     * is missing from the stored option — a fresh install can leave the row
     * out entirely and still emit the line. We record only whether that
     * fallback is empty, never the text: those defaults are translated, so
     * copying the English string would misjudge a non-English site.
     *
     * Verified against the real plugin source, not against documentation:
     *  - Yoast SEO 26.4      inc/options/class-wpseo-option-titles.php:53-54, 277
     *                        src/integrations/front-end/rss-footer-embed.php:114
     *  - Rank Math 1.0.273   includes/settings/general/others.php:114-127
     *                        includes/class-installer.php:404-405 (both default '')
     *                        includes/frontend/class-frontend.php:246
     *  - AIOSEO Lite 4.9.10  app/Common/Options/Options.php:63-68 (after has a
     *                        non-empty default), app/Common/Rss.php:87-88
     *
     * SEOPress deliberately has no row: SEOPress FREE has no feed text at all
     * (it is a PRO-only feature), so it cannot cause the duplicate. SEOPress
     * PRO can, but it is not installed anywhere locally, so its option names
     * are not verifiable — do not add a row from memory or from a third
     * party's importer. Source: the SEOPress code analysis in the vault,
     * `Marketing/Concurrentie - SEOPress (code-analyse).md` — "Niet in de
     * gratis code: ... RSS-aanpassingen" (line 153) and "Custom RSS-feed" in
     * the PRO table (line 205, citing their readme.txt:118).
     *
     * @return array<string, array>
     */
    public static function get_feed_text_sources()
    {
        return array(
            'yoast' => array(
                'name'         => 'Yoast SEO',
                'plugins'      => array(
                    'wordpress-seo/wp-seo.php',
                    'wordpress-seo-premium/wp-seo-premium.php',
                ),
                'option'       => 'wpseo_titles',
                'encoding'     => 'array',
                'fields'       => array(
                    array('path' => array('rssbefore'), 'default_filled' => false),
                    array('path' => array('rssafter'),  'default_filled' => true),
                ),
                'settings_url' => 'admin.php?page=wpseo_page_settings#/rss',
            ),
            'rankmath' => array(
                'name'         => 'Rank Math',
                'plugins'      => array(
                    'seo-by-rank-math/rank-math.php',
                    'seo-by-rank-math-pro/rank-math-pro.php',
                ),
                'option'       => 'rank-math-options-general',
                'encoding'     => 'array',
                'fields'       => array(
                    array('path' => array('rss_before_content'), 'default_filled' => false),
                    array('path' => array('rss_after_content'),  'default_filled' => false),
                ),
                'settings_url' => 'admin.php?page=rank-math-options-general&tab=others',
            ),
            'aioseo' => array(
                'name'         => 'All in One SEO',
                'plugins'      => array(
                    'all-in-one-seo-pack/all_in_one_seo_pack.php',
                    'all-in-one-seo-pack-pro/all_in_one_seo_pack.php',
                ),
                'option'       => 'aioseo_options',
                'encoding'     => 'json',
                'fields'       => array(
                    array('path' => array('rssContent', 'before'), 'default_filled' => false),
                    array('path' => array('rssContent', 'after'),  'default_filled' => true),
                ),
                'settings_url' => 'admin.php?page=aioseo-settings#/rss-content',
            ),
        );
    }

    /**
     * The warnings to show in the RSS card, gate included.
     *
     * A second line only exists when BOTH sides add one, so nothing is
     * reported while our own feed text is switched off — no matter what the
     * other plugin has configured.
     *
     * @return array<int, array{key: string, name: string, settings_url: string}>
     */
    public static function get_feed_text_warnings()
    {
        $settings = self::get_settings();
        if (empty($settings['enabled'])) {
            return array();
        }

        return self::detect_feed_text_conflicts();
    }

    /**
     * Which other plugins would double up the text in every feed item.
     *
     * The condition is deliberately two-part: the plugin must be ACTIVE and
     * its feed text must be non-empty. A deactivated plugin leaves its options
     * in the database — Yoast's `rssafter` survives deactivation — but adds
     * nothing to the feed, so warning about it would be wrong.
     *
     * Reads only; never writes to another plugin's options.
     *
     * @return array<int, array{key: string, name: string, settings_url: string}>
     */
    public static function detect_feed_text_conflicts()
    {
        $conflicts = array();

        foreach (self::get_feed_text_sources() as $key => $source) {
            if (!self::is_source_active($source['plugins'])) {
                continue;
            }

            $has_text = false;
            foreach ($source['fields'] as $field) {
                if (self::field_is_filled($source['option'], $source['encoding'], $field)) {
                    $has_text = true;
                    break;
                }
            }

            if (!$has_text) {
                continue;
            }

            $conflicts[] = array(
                'key'          => $key,
                'name'         => $source['name'],
                'settings_url' => admin_url($source['settings_url']),
            );
        }

        return $conflicts;
    }

    /**
     * Is any of these plugin files active?
     *
     * is_plugin_active() lives in wp-admin/includes/plugin.php, which is not
     * guaranteed to be loaded at every point we may be called from, so fall
     * back to the raw option (plus the network list on multisite).
     *
     * @param string[] $plugin_files
     * @return bool
     */
    private static function is_source_active($plugin_files)
    {
        foreach ($plugin_files as $file) {
            if (function_exists('is_plugin_active')) {
                if (is_plugin_active($file)) {
                    return true;
                }
                continue;
            }

            $active = (array) get_option('active_plugins', array());
            if (in_array($file, $active, true)) {
                return true;
            }

            if (is_multisite()) {
                $network = (array) get_site_option('active_sitewide_plugins', array());
                if (isset($network[$file])) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Does one field of another plugin hold text that ends up in the feed?
     *
     * A key that is absent from the stored option is NOT the same as an empty
     * key: the plugin then resolves its own default, which for Yoast's
     * `rssafter` and AIOSEO's `rssContent.after` is a filled line.
     *
     * @param string $option_name
     * @param string $encoding 'array' (a WP option array) or 'json' (a JSON string).
     * @param array  $field    { path: string[], default_filled: bool }
     * @return bool
     */
    private static function field_is_filled($option_name, $encoding, $field)
    {
        $stored = get_option($option_name, null);

        if ($encoding === 'json' && is_string($stored)) {
            $stored = json_decode($stored, true);
        }

        if (!is_array($stored)) {
            // No usable option row at all — the plugin uses its own default.
            return !empty($field['default_filled']);
        }

        $node = $stored;
        foreach ($field['path'] as $key) {
            if (!is_array($node) || !array_key_exists($key, $node)) {
                return !empty($field['default_filled']);
            }
            $node = $node[$key];
        }

        if (is_array($node) || is_object($node)) {
            return false;
        }

        // Whitespace-only text produces no visible second line.
        return trim((string) $node) !== '';
    }

    /**
     * Filter for the full-content feed.
     *
     * @param string $content   Feed content.
     * @param string $feed_type Feed type (rss2, atom, ...). Unused.
     * @return string
     */
    public function filter_content($content, $feed_type = null)
    {
        return $this->decorate($content);
    }

    /**
     * Filter for the excerpt feed.
     *
     * @param string $excerpt Feed excerpt.
     * @return string
     */
    public function filter_excerpt($excerpt)
    {
        return $this->decorate($excerpt);
    }

    /**
     * Prepend/append the configured text to one feed item.
     *
     * @param string $content
     * @return string
     */
    private function decorate($content)
    {
        // Belt and braces: these filters only fire on feed requests, but a theme
        // or plugin can apply them anywhere.
        if (!is_feed()) {
            return $content;
        }

        $settings = self::get_settings();
        if (empty($settings['enabled'])) {
            return $content;
        }

        $before = self::render_template($settings['before']);
        $after  = self::render_template($settings['after']);

        if ($before === '' && $after === '') {
            return $content;
        }

        $out = '';
        if ($before !== '') {
            $out .= '<p>' . $before . '</p>' . "\n";
        }
        $out .= $content;
        if ($after !== '') {
            $out .= "\n" . '<p>' . $after . '</p>';
        }

        return $out;
    }

    /**
     * Replace the placeholders in one template string.
     *
     * Returns HTML: the link placeholders expand to anchors, so the result is run
     * through wp_kses_post() rather than esc_html() — escaping the whole string
     * would show the tags literally in the feed. Every value that goes INTO the
     * markup is escaped individually first.
     *
     * @param string       $template Raw template text.
     * @param int|WP_Post  $post     Post to resolve the placeholders against. Defaults to the current post.
     * @return string Safe HTML, or '' when the template is empty.
     */
    public static function render_template($template, $post = null)
    {
        $template = trim((string) $template);
        if ($template === '') {
            return '';
        }

        $post = get_post($post);

        $post_link  = '';
        $post_title = '';
        $author_link = '';

        if ($post instanceof WP_Post) {
            $title      = get_the_title($post);
            $post_title = esc_html($title);
            $post_link  = '<a href="' . esc_url(get_permalink($post)) . '">' . esc_html($title) . '</a>';

            // Resolve the author through get_userdata(), not get_the_author_meta():
            // the latter falls back to the global $authordata when the id is 0, so
            // an authorless post would silently borrow someone else's name and get
            // a broken /author/ URL.
            $author_id = (int) $post->post_author;
            $author    = $author_id > 0 ? get_userdata($author_id) : false;
            if ($author) {
                $author_name = (string) $author->display_name;
                $author_url  = get_author_posts_url($author_id);
                if ($author_name !== '' && $author_url !== '') {
                    $author_link = '<a href="' . esc_url($author_url) . '">' . esc_html($author_name) . '</a>';
                } elseif ($author_name !== '') {
                    $author_link = esc_html($author_name);
                }
            }
        }

        $site_link = '<a href="' . esc_url(home_url('/')) . '">'
            . esc_html(get_bloginfo('name')) . '</a>';

        $replacements = array(
            '%POSTLINK%'   => $post_link,
            '%SITELINK%'   => $site_link,
            '%AUTHORLINK%' => $author_link,
            '%POSTTITLE%'  => $post_title,
        );

        $rendered = strtr($template, $replacements);

        return wp_kses_post($rendered);
    }
}
