<?php

/**
 * TamRank IndexNow — instant URL notification to the IndexNow search engines.
 *
 * IndexNow is the Microsoft-originated protocol behind api.indexnow.org. One
 * submit is shared with every participating engine (Amazon, Bing, Naver,
 * Seznam.cz, Yandex, Yep), so this is a separate path from the Google Indexing
 * API that TamRank_Indexing drives — the two do not overlap and neither
 * replaces the other.
 *
 * The plugin never talks to a search engine directly: the chain is
 * plugin -> api.tamrank.com -> api.indexnow.org, the same shape the Google
 * indexing request already uses. This class owns the site key, the ownership
 * proof (served by includes/indexnow-key-file.php), which URLs qualify, and
 * how the answer is read back.
 *
 * OFF by default. Turning it on is the moment a site first needs an api_key,
 * so the toggle — not activation — triggers the registration handshake.
 *
 * @package TamRank
 */

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

class TamRank_IndexNow
{
    /** Site key, stored with autoload=no (see ensure_key). */
    const OPTION_KEY = 'tam_rank_indexnow_key';

    /** Outcome of the most recent submit, rendered on the settings screen. */
    const OPTION_LAST_RESULT = 'tam_rank_indexnow_last_result';

    /** Single-event cron hook carrying one post ID. */
    const CRON_HOOK = 'tamrank_indexnow_submit';

    /**
     * Allowed key charset.
     *
     * The IndexNow documentation contradicts itself here: it says "a minimum of
     * 8 and a maximum of 128 hexadecimal characters" and, in the very next
     * sentence, "The key can contain only the following characters: lowercase
     * (a-z), uppercase (A-Z), numbers (0-9), and dashes (-)". We validate
     * against the explicit enumeration (the superset) so a key a search engine
     * would accept is never refused here, and we GENERATE hex — which satisfies
     * both readings at once. See generate_key().
     */
    const KEY_PATTERN = '/^[A-Za-z0-9-]{8,128}$/';

    /**
     * Hard ceiling on one submit, straight from the protocol.
     *
     * The VPS endpoint refuses more than this with `400 too_many_urls` and does
     * NOT split the batch, so the chunking has to happen on this side.
     */
    const MAX_URLS_PER_SUBMIT = 10000;

    private static $instance = null;

    public static function get_instance()
    {
        if (null === self::$instance) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct()
    {
        add_action('transition_post_status', array($this, 'maybe_schedule_submit'), 10, 3);
        add_action(self::CRON_HOOK, array($this, 'do_submit'));

        // Turning the toggle on is an explicit user action, and it is the point
        // where a FREE-only site first needs an api_key. Both option hooks are
        // wired because the very first save of the settings group is an
        // add_option, not an update_option.
        add_action('update_option_tam_rank_general_settings', array($this, 'on_settings_saved'), 10, 2);
        add_action('add_option_tam_rank_general_settings', array($this, 'on_settings_added'), 10, 2);
    }

    // ── Setting ──────────────────────────────────────────────────

    /**
     * Is IndexNow switched on? Off by default — a fresh install makes no calls.
     *
     * @return bool
     */
    public static function is_enabled()
    {
        $settings = function_exists('tam_rank_cached_option')
            ? tam_rank_cached_option('tam_rank_general_settings', array())
            : get_option('tam_rank_general_settings', array());

        return !empty($settings['enable_indexnow']);
    }

    /**
     * React to the toggle flipping on: mint the site key and make sure the site
     * has an api_key.
     *
     * ensure_api_key() runs the ownership handshake over api.tamrank.com. That
     * is a network call and a domain handshake, which is exactly why it hangs
     * off a deliberate user action instead of plugin activation — the same
     * "site registration on explicit action" rule the Search Console connect
     * button follows.
     *
     * @param mixed $old Previous option value.
     * @param mixed $new New option value.
     * @return void
     */
    public function on_settings_saved($old, $new)
    {
        $was = is_array($old) && !empty($old['enable_indexnow']);
        $now = is_array($new) && !empty($new['enable_indexnow']);

        if ($was || !$now) {
            return;
        }

        $this->handle_enable();
    }

    /**
     * First-ever save of the settings group (add_option, not update_option).
     *
     * @param string $option Option name.
     * @param mixed  $value  Stored value.
     * @return void
     */
    public function on_settings_added($option, $value)
    {
        if (is_array($value) && !empty($value['enable_indexnow'])) {
            $this->handle_enable();
        }
    }

    /**
     * The work behind "the user just switched IndexNow on".
     *
     * @return void
     */
    private function handle_enable()
    {
        self::ensure_key();

        if (!class_exists('\\TamRank\\LicenseManager')) {
            return;
        }

        $license = new \TamRank\LicenseManager();
        $ensured = $license->ensure_api_key();

        if (is_wp_error($ensured)) {
            self::record_result(array(
                'state'           => 'no_api_key',
                'indexnow_status' => 0,
                'submitted'       => 0,
                'urls'            => 0,
                'message'         => $ensured->get_error_message(),
            ));
        }
    }

    // ── Site key + ownership proof ───────────────────────────────

    /**
     * The stored site key, or '' when none was minted yet or the stored value
     * no longer satisfies the protocol charset.
     *
     * @return string
     */
    public static function get_key()
    {
        $key = get_option(self::OPTION_KEY, '');
        if (!is_string($key) || $key === '') {
            return '';
        }

        return preg_match(self::KEY_PATTERN, $key) ? $key : '';
    }

    /**
     * Return the site key, minting one on first use.
     *
     * Stored with autoload=no for the same reason the api_key is (see
     * tam_rank_ensure_api_keys_not_autoloaded): the alloptions cache ends up in
     * plenty of backup and debug exports, and the IndexNow key is a shared
     * secret between this site and the search engines — anyone holding it can
     * submit URLs for this host.
     *
     * @return string
     */
    public static function ensure_key()
    {
        $key = self::get_key();
        if ($key !== '') {
            return $key;
        }

        $key = self::generate_key();

        // delete + add, because update_option() cannot change an existing row's
        // autoload flag (mirrors tam_rank_ensure_api_keys_not_autoloaded).
        delete_option(self::OPTION_KEY);
        add_option(self::OPTION_KEY, $key, '', 'no');

        return $key;
    }

    /**
     * Mint a 32-character lowercase hex key.
     *
     * Hex is a strict subset of the documented `a-zA-Z0-9-` enumeration, so it
     * is valid under BOTH readings of the contradictory charset sentence in the
     * IndexNow documentation — the safe side of an ambiguity we cannot resolve
     * from the outside.
     *
     * @return string
     */
    private static function generate_key()
    {
        try {
            return bin2hex(random_bytes(16));
        } catch (\Exception $e) {
            // random_bytes() can only fail when the platform has no CSPRNG.
            // wp_generate_password() falls back to wp_rand(); hashing keeps the
            // result inside the hex charset either way.
            return md5(wp_generate_password(64, true, true) . microtime(true));
        }
    }

    /**
     * Public URL of the ownership-proof file.
     *
     * Always sent as keyLocation. The protocol treats it as optional when the
     * file sits in the root, but the TamRank endpoint requires it, and a
     * subdirectory install has no host root to write to anyway.
     *
     * @return string Empty string when no key exists.
     */
    public static function key_url()
    {
        $key = self::get_key();
        return $key === '' ? '' : home_url('/' . $key . '.txt');
    }

    /**
     * The host IndexNow is told about.
     *
     * Returned verbatim from home_url(), `www.` included. The VPS compares this
     * against the registered site URL without stripping anything, and IndexNow
     * itself treats www.example.com and example.com as different hosts — so
     * normalising here would break the match, not fix it.
     *
     * @return string
     */
    public static function submit_host()
    {
        $host = wp_parse_url(home_url(), PHP_URL_HOST);
        return is_string($host) ? $host : '';
    }

    // ── Which URLs qualify ───────────────────────────────────────

    /**
     * Should this post be announced to IndexNow?
     *
     * Deliberately the same exclusions the XML sitemap applies (published,
     * enabled content type, not noindex) — a URL we keep out of the sitemap is
     * one we should not be pushing at search engines either.
     *
     * @param WP_Post|int $post
     * @return bool
     */
    public static function should_submit_post($post)
    {
        $post = get_post($post);
        if (!$post) {
            return false;
        }

        if ($post->post_status !== 'publish') {
            return false;
        }

        if (wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) {
            return false;
        }

        $post_type_obj = get_post_type_object($post->post_type);
        if (!$post_type_obj || !$post_type_obj->public) {
            return false;
        }

        // Content types the user switched off for the sitemap are off here too.
        if (class_exists('TamRank_Content_Type_Config')) {
            $enabled = TamRank_Content_Type_Config::get_enabled_sitemap_slugs();
            if (!in_array($post->post_type, (array) $enabled, true)) {
                return false;
            }
        }

        // Per-page noindex: same meta key and same '1' test the sitemap uses in
        // get_post_type_urls(). Announcing a noindex URL asks a search engine to
        // crawl a page we then tell it not to index.
        if (get_post_meta($post->ID, '_tam_rank_noindex', true) == '1') {
            return false;
        }

        // Password-protected content is not publicly readable, so there is
        // nothing for a crawler to fetch.
        if ($post->post_password !== '') {
            return false;
        }

        return true;
    }

    // ── Publish / update hook ────────────────────────────────────

    /**
     * Queue a submit when a post becomes (or stays) published.
     *
     * transition_post_status fires for both cases we want — a fresh publish
     * (draft -> publish) and an edit of a live page (publish -> publish) — and
     * for none of the ones we don't: an auto-draft never reaches 'publish', and
     * revisions/autosaves are filtered out below.
     *
     * @param string  $new_status
     * @param string  $old_status
     * @param WP_Post $post
     * @return void
     */
    public function maybe_schedule_submit($new_status, $old_status, $post)
    {
        if (!self::is_enabled()) {
            return;
        }

        if ($new_status !== 'publish') {
            return;
        }

        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }

        if (!self::should_submit_post($post)) {
            return;
        }

        // The 15-second delay is also what de-duplicates: wp_schedule_single_event()
        // refuses a second event with the same hook and args within 10 minutes of
        // one already pending, so the double save Gutenberg can produce inside a
        // single request collapses into one submit. Once the event has run, a
        // genuinely later edit schedules a fresh one — which is the whole point
        // of IndexNow.
        wp_schedule_single_event(time() + 15, self::CRON_HOOK, array($post->ID));
    }

    /**
     * Cron callback: submit one post's permalink.
     *
     * @param int $post_id
     * @return void
     */
    public function do_submit($post_id)
    {
        if (!self::is_enabled()) {
            return;
        }

        // Re-check at dispatch time: between scheduling and running, the page
        // may have been unpublished, trashed or set to noindex.
        if (!self::should_submit_post($post_id)) {
            return;
        }

        $url = get_permalink($post_id);
        if (!$url) {
            return;
        }

        self::submit_urls(array($url));
    }

    // ── Submitting ───────────────────────────────────────────────

    /**
     * Send URLs to IndexNow through api.tamrank.com.
     *
     * @param array $urls Absolute URLs on this host.
     * @return array The recorded result (see record_result).
     */
    public static function submit_urls(array $urls)
    {
        $host = self::submit_host();
        if ($host === '') {
            return self::record_result(array(
                'state'   => 'error',
                'urls'    => 0,
                'message' => tamrank__('indexnow_err_no_host', 'Could not determine this site\'s host name.'),
            ));
        }

        // Only URLs on the submitted host. IndexNow answers 422 for a URL that
        // does not belong to the host, and one stray URL fails the whole batch.
        $clean = array();
        foreach ($urls as $url) {
            $url = esc_url_raw((string) $url);
            if ($url === '') {
                continue;
            }
            if (wp_parse_url($url, PHP_URL_HOST) !== $host) {
                continue;
            }
            $clean[$url] = $url;
        }
        $clean = array_values($clean);

        if (empty($clean)) {
            return self::record_result(array(
                'state'   => 'error',
                'urls'    => 0,
                'message' => tamrank__('indexnow_err_no_urls', 'No URLs on this site were eligible for submission.'),
            ));
        }

        $key = self::ensure_key();
        if ($key === '') {
            return self::record_result(array(
                'state'   => 'error',
                'urls'    => count($clean),
                'message' => tamrank__('indexnow_err_no_key', 'No IndexNow key could be created for this site.'),
            ));
        }

        if (!class_exists('TamRank_API_Client')) {
            return self::record_result(array(
                'state'   => 'error',
                'urls'    => count($clean),
                'message' => tamrank__('indexnow_err_unavailable', 'IndexNow is temporarily unavailable.'),
            ));
        }

        $api      = TamRank_API_Client::get_instance();
        $location = self::key_url();
        $last     = null;

        // Chunk locally: the endpoint refuses an oversized batch outright
        // rather than splitting it.
        foreach (array_chunk($clean, self::MAX_URLS_PER_SUBMIT) as $chunk) {
            $response = $api->submit_indexnow($host, $key, $location, $chunk);
            $last     = self::interpret($response, count($chunk));
        }

        return self::record_result($last);
    }

    /**
     * Turn one API answer into the state the UI shows.
     *
     * The trap this method exists for: the TamRank API answers 200 whenever it
     * successfully reached IndexNow — including when IndexNow itself said 403 or
     * 429. Our own HTTP status only reports OUR side of the chain. The verdict
     * that matters is `indexnow_status`, so that is what is read here.
     *
     * @param array|WP_Error $response  Answer from TamRank_API_Client.
     * @param int            $url_count URLs in this chunk.
     * @return array
     */
    private static function interpret($response, $url_count)
    {
        if (is_wp_error($response)) {
            return array(
                'state'           => $response->get_error_code() === 'indexnow_unavailable'
                    ? 'unavailable'
                    : 'error',
                'indexnow_status' => 0,
                'submitted'       => 0,
                'urls'            => $url_count,
                'message'         => $response->get_error_message(),
            );
        }

        $status    = isset($response['indexnow_status']) ? (int) $response['indexnow_status'] : 0;
        $submitted = isset($response['submitted']) ? (int) $response['submitted'] : 0;

        switch ($status) {
            case 200:
                return array(
                    'state'           => 'ok',
                    'indexnow_status' => $status,
                    'submitted'       => $submitted,
                    'urls'            => $url_count,
                    'message'         => sprintf(
                        /* translators: %d: number of URLs accepted by IndexNow */
                        tamrank__('indexnow_state_ok', '%d URL(s) submitted to IndexNow.'),
                        $submitted
                    ),
                );

            case 202:
                return array(
                    'state'           => 'pending',
                    'indexnow_status' => $status,
                    'submitted'       => $submitted,
                    'urls'            => $url_count,
                    'message'         => tamrank__('indexnow_state_pending', 'Submitted. The search engines are still verifying your key file — this is normal for the first submission and needs no action.'),
                );

            case 403:
                return array(
                    'state'           => 'key_invalid',
                    'indexnow_status' => $status,
                    'submitted'       => 0,
                    'urls'            => $url_count,
                    'message'         => tamrank__('indexnow_state_key_invalid', 'The search engines could not read your key file, so nothing was submitted. Check that the key file link opens in your browser.'),
                );

            case 422:
                return array(
                    'state'           => 'host_mismatch',
                    'indexnow_status' => $status,
                    'submitted'       => 0,
                    'urls'            => $url_count,
                    'message'         => tamrank__('indexnow_state_host_mismatch', 'The submitted URLs do not match the host of your key file, so nothing was submitted.'),
                );

            case 429:
                return array(
                    'state'           => 'rate_limited',
                    'indexnow_status' => $status,
                    'submitted'       => 0,
                    'urls'            => $url_count,
                    'message'         => tamrank__('indexnow_state_rate_limited', 'The search engines are rate-limiting this site, so nothing was submitted. Publishing less often resolves this on its own.'),
                );

            default:
                return array(
                    'state'           => 'error',
                    'indexnow_status' => $status,
                    'submitted'       => 0,
                    'urls'            => $url_count,
                    'message'         => isset($response['message']) && is_string($response['message']) && $response['message'] !== ''
                        ? $response['message']
                        : tamrank__('indexnow_state_error', 'The submission did not go through. Try again later.'),
                );
        }
    }

    /**
     * Persist the outcome of the most recent submit for the settings screen.
     *
     * @param array|null $result
     * @return array
     */
    private static function record_result($result)
    {
        $result = is_array($result) ? $result : array();

        $result = array_merge(array(
            'state'           => 'error',
            'indexnow_status' => 0,
            'submitted'       => 0,
            'urls'            => 0,
            'message'         => '',
            'time'            => time(),
        ), $result);

        $result['time'] = time();

        update_option(self::OPTION_LAST_RESULT, $result, false);

        return $result;
    }

    /**
     * The stored outcome of the most recent submit, or null.
     *
     * @return array|null
     */
    public static function get_last_result()
    {
        $result = get_option(self::OPTION_LAST_RESULT, null);
        return is_array($result) ? $result : null;
    }
}
