<?php

/**
 * TamRank Indexing: Google Search Console indexing requests (FREE).
 *
 * Handles automatic indexing requests on publish and the manual "Request
 * indexing" action (fire-and-forget) on its three surfaces: the posts/pages list
 * row action, the SEO meta box on the post edit screen and the SEO panel on the
 * term edit screen. All three post to the same wp_ajax_tam_rank_request_indexing
 * handler. The always-visible index status
 * column, the contextual banners and the bulk-scan engine moved to the
 * tam-rank-pro add-on during the cluster-5 strip; PRO reuses the public static
 * has_property_mismatch() helper kept here (the FREE auto-index path needs it
 * too, so it stays in FREE as the shared GSC-property guard).
 */

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

class TamRank_Indexing
{
    private static $instance = null;

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

    private function __construct()
    {
        // Auto-indexing on publish (FREE)
        add_action('transition_post_status', array($this, 'maybe_schedule_indexing'), 10, 3);
        add_action('tamrank_request_indexing', array($this, 'do_request_indexing'));

        // Row actions (manual request indexing stays FREE)
        add_filter('post_row_actions', array($this, 'add_row_action'), 10, 2);
        add_filter('page_row_actions', array($this, 'add_row_action'), 10, 2);

        // AJAX — manual request indexing (fire-and-forget). The status/refresh
        // endpoints paired with the PRO column moved to tam-rank-pro.
        add_action('wp_ajax_tam_rank_request_indexing', array($this, 'ajax_request_indexing'));
    }

    // ── Shared request cooldown (source of truth for FREE + PRO) ──

    /**
     * The "one indexing request per page per window" cooldown, in seconds.
     *
     * Single source of truth honoured by EVERY surface that can ask Google to
     * (re)crawl a URL: this FREE auto-index + manual "Request indexing" button and
     * the PRO agent recrawl endpoint (which delegates here). They all key the
     * window on the _tamrank_index_requested_at post-meta, so none of them can be
     * bypassed by going through another surface. Re-requesting sooner is pointless
     * — Google de-dupes and rarely acts within the window — so the default is a
     * deliberately wide 48h.
     *
     * @return int Cooldown length in seconds. Filter: tamrank_index_request_cooldown.
     */
    public static function request_cooldown()
    {
        return (int) apply_filters('tamrank_index_request_cooldown', 2 * DAY_IN_SECONDS);
    }

    /**
     * Seconds left on an object's indexing-request cooldown, or 0 if a new
     * request is allowed. Reads _tamrank_index_requested_at — the timestamp every
     * request surface writes on a successful submit.
     *
     * Terms keep the very same meta key in term-meta, so a term archive gets its
     * own 48h window without borrowing (or blocking) a post's.
     *
     * @param int    $object_id
     * @param string $object_type 'post' (default) or 'term'.
     * @return int Seconds remaining (0 = not in cooldown / never requested).
     */
    public static function request_cooldown_remaining($object_id, $object_type = 'post')
    {
        $requested = ('term' === $object_type)
            ? (int) get_term_meta($object_id, '_tamrank_index_requested_at', true)
            : (int) get_post_meta($object_id, '_tamrank_index_requested_at', true);
        if (!$requested) {
            return 0;
        }
        $elapsed  = time() - $requested;
        $cooldown = self::request_cooldown();
        return ($elapsed < $cooldown) ? ($cooldown - $elapsed) : 0;
    }

    // ── Auto-indexing ────────────────────────────────────────────

    public function maybe_schedule_indexing($new_status, $old_status, $post)
    {
        if ($new_status !== 'publish') {
            return;
        }

        // Skip autosave and revisions
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }
        if (wp_is_post_revision($post->ID)) {
            return;
        }

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

        // Check setting
        $settings = function_exists('tam_rank_cached_option') ? tam_rank_cached_option('tam_rank_general_settings', array()) : get_option('tam_rank_general_settings', array());
        if (empty($settings['enable_auto_indexing'])) {
            return;
        }

        // Skip noindex pages — don't request indexing for pages that shouldn't be indexed
        if (get_post_meta($post->ID, '_tam_rank_noindex', true)) {
            return;
        }

        // Skip on property mismatch — request would hit GSC against a property
        // Google has no data for; wastes API quota and writes a stale error
        // status to post-meta.
        if (self::has_property_mismatch()) {
            return;
        }

        // Shared cooldown: a manual or agent request (or an earlier publish) already
        // submitted this page within the window — don't fire a duplicate Google
        // request that would only be de-duped anyway.
        if (self::request_cooldown_remaining($post->ID) > 0) {
            return;
        }

        // 5 min debounce
        $transient_key = 'tamrank_indexing_' . $post->ID;
        if (get_transient($transient_key)) {
            return;
        }
        set_transient($transient_key, 1, 5 * MINUTE_IN_SECONDS);

        // Schedule async (15 sec delay)
        wp_schedule_single_event(time() + 15, 'tamrank_request_indexing', array($post->ID));
    }

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

        // Defensive guard: catches cron jobs queued *before* a mismatch was
        // introduced (e.g. user reconnects GSC with the wrong account between
        // schedule + dispatch).
        if (self::has_property_mismatch()) {
            return;
        }

        // Cross-surface cooldown: a manual or agent request between scheduling and
        // dispatch already covered this page — don't double-submit.
        if (self::request_cooldown_remaining($post_id) > 0) {
            return;
        }

        $api    = TamRank_API_Client::get_instance();
        $result = $api->request_indexing($url);

        // Record the request so the shared cooldown window (FREE manual button, the
        // PRO column spinner and the PRO recrawl endpoint all read this meta) holds
        // for this page too. Only a request Google actually accepted opens the window.
        if (!is_wp_error($result)) {
            update_post_meta($post_id, '_tamrank_index_requested_at', time());
        }
    }

    // ── Row action ───────────────────────────────────────────────

    public function add_row_action($actions, $post)
    {
        if ($post->post_status !== 'publish') {
            return $actions;
        }

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

        // Requires Google Search Console to be connected — otherwise the link
        // leads to an action we cannot complete.
        if (!class_exists('TamRank_GSC_Connector') || !TamRank_GSC_Connector::is_connected()) {
            return $actions;
        }

        $actions['tamrank_index'] = sprintf(
            '<a href="#" class="tamrank-index-link" data-post-id="%d">%s</a>',
            $post->ID,
            esc_html(tamrank__('idx_request_indexing', 'Request indexing'))
        );

        return $actions;
    }

    // ── Edit-screen action (post + term) ─────────────────────────

    /**
     * May this screen offer a manual indexing request for this object?
     *
     * Deliberately the same gate set the list-table row action renders on —
     * published/publicly reachable, a public type, Search Console connected —
     * plus the object-level capability, because the edit screen is a place a
     * user can land on for an object somebody else owns. Nothing here is new
     * policy; it is the row action's gate expressed once so the post screen and
     * the term screen cannot drift apart.
     *
     * @param string $object_type 'post' or 'term'.
     * @param int    $object_id
     * @return bool
     */
    public static function can_request_indexing($object_type, $object_id)
    {
        // Requires Google Search Console to be connected — otherwise the button
        // leads to an action we cannot complete.
        if (!class_exists('TamRank_GSC_Connector') || !TamRank_GSC_Connector::is_connected()) {
            return false;
        }

        if ('term' === $object_type) {
            $term = get_term((int) $object_id);
            if (!$term || is_wp_error($term)) {
                return false;
            }

            // A term archive is only worth submitting when its taxonomy is
            // public — the same "public post type" rule, one level over.
            $taxonomy = get_taxonomy($term->taxonomy);
            if (!$taxonomy || !$taxonomy->public) {
                return false;
            }

            if (!current_user_can('edit_term', $term->term_id)) {
                return false;
            }

            $link = get_term_link($term);
            return !is_wp_error($link) && !empty($link);
        }

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

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

        if (!current_user_can('edit_post', $post->ID)) {
            return false;
        }

        return (bool) get_permalink($post->ID);
    }

    /**
     * Print the "Request indexing" action for an edit screen.
     *
     * One renderer for both surfaces that offer the action outside the posts
     * list — the SEO meta box and the term SEO panel — so they cannot end up
     * with two different buttons wired to two different payloads. Prints
     * nothing at all when the gates say no — the absent button IS the gate on a
     * draft, a private page or a site without GSC.
     *
     * Shape (Sam, 25 + 26 Aug): a secondary action, not a podium, and it sits
     * on the tab bar's own row at the far right rather than on a line of its
     * own. It is used at most once per change and then sits out a 48h window,
     * so the available state is one quiet ghost button with the "why would I
     * click this" sentence in its tooltip (title= is kept as the no-JS
     * fallback). The cooldown state is the one state that carries real
     * information — when you may ask again — so there the sentence stays on
     * screen as text; a disabled button fires no mouse events, so a tooltip
     * could not carry it. That sentence is wider than the room next to four
     * tabs, so the tab bar wraps and the action takes its own right-aligned
     * line for the days it is in cooldown.
     *
     * @param string $object_type 'post' or 'term'.
     * @param int    $object_id
     * @return void
     */
    public static function render_edit_screen_action($object_type, $object_id)
    {
        if (!self::can_request_indexing($object_type, $object_id)) {
            return;
        }

        $object_type   = ('term' === $object_type) ? 'term' : 'post';
        $object_id     = (int) $object_id;
        $cooldown_left = self::request_cooldown_remaining($object_id, $object_type);

        $hint = $cooldown_left > 0
            ? sprintf(
                /* translators: %d: hours until indexing can be requested again */
                tamrank__('idx_edit_cooldown', 'Already requested. Available again in about %d h.'),
                max(1, (int) ceil($cooldown_left / HOUR_IN_SECONDS))
            )
            : tamrank__('idx_edit_hint', 'Ask Google to crawl this page again after your changes.');
        ?>
        <div class="tr-index-request">
            <button type="button"
                class="tr-btn tr-btn--ghost tr-btn--sm tamrank-index-link"
                data-object-type="<?php echo esc_attr($object_type); ?>"
                <?php if ('term' === $object_type) : ?>
                data-term-id="<?php echo esc_attr($object_id); ?>"
                <?php else : ?>
                data-post-id="<?php echo esc_attr($object_id); ?>"
                <?php endif; ?>
                <?php if (0 === $cooldown_left) : ?>
                title="<?php echo esc_attr($hint); ?>"
                data-tr-tooltip="<?php echo esc_attr($hint); ?>"
                data-tr-tooltip-position="top"
                <?php endif; ?>
                <?php disabled($cooldown_left > 0); ?>>
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                    <path d="M21 12a9 9 0 1 1-2.64-6.36" />
                    <polyline points="21 3 21 9 15 9" />
                </svg>
                <span class="tamrank-index-link__label"><?php echo esc_html(tamrank__('idx_request_indexing', 'Request indexing')); ?></span>
            </button>
            <?php if ($cooldown_left > 0) : ?>
            <span class="tr-index-request__hint"><?php echo esc_html($hint); ?></span>
            <?php endif; ?>
        </div>
        <?php
    }

    // ── AJAX ─────────────────────────────────────────────────────

    public function ajax_request_indexing()
    {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        // Term archives take the same route through this handler: same nonce,
        // same property guard, same _tamrank_index_requested_at cooldown key and
        // the same TamRank_API_Client::request_indexing() call. Only the way a
        // URL is resolved differs, so that part branches off and nothing on the
        // post path below changes.
        if (isset($_POST['object_type']) && 'term' === sanitize_key(wp_unslash($_POST['object_type']))) {
            $this->request_indexing_for_term();
            return; // request_indexing_for_term() always ends in wp_send_json_*.
        }

        if (!current_user_can('edit_posts')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'), 403);
        }

        // Property mismatch: GSC has no data for this site's URLs, so the
        // request would hit the wrong property. Refuse with a clear message
        // instead of silently spending API quota.
        if (self::has_property_mismatch()) {
            wp_send_json_error(array(
                'message' => tamrank__('idx_mismatch_no_data', 'Wrong GSC property connected. See banner above.'),
                'code'    => 'property_mismatch',
            ), 409);
        }

        $post_id = intval($_POST['post_id'] ?? 0);
        if (!$post_id) {
            wp_send_json_error(array('message' => tamrank__('error_invalid_post', 'This post could not be found.'), 'code' => 'not_found'));
        }

        // Object-level authorization: the generic edit_posts gate above is not
        // enough — verify the user may edit THIS specific post before spending
        // indexing quota on its URL.
        if (!current_user_can('edit_post', $post_id)) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'), 403);
        }

        // Published only — the rule the row action has always rendered on, now
        // enforced here as well. The list-table link is never drawn for a draft,
        // so this can only fire on a request the UI did not make; with the button
        // living on the edit screen too, that rule needs a server-side seat.
        if ('publish' !== get_post_status($post_id)) {
            wp_send_json_error(array(
                'message' => tamrank__('idx_not_published', 'Indexing can only be requested for a published page.'),
                'code'    => 'not_published',
            ), 400);
        }

        $url = get_permalink($post_id);
        if (!$url) {
            wp_send_json_error(array('message' => tamrank__('error_url_determine', 'Could not determine the URL.'), 'code' => 'not_found'));
        }

        // One request per page per window. Without this the button could be clicked
        // repeatedly and fire a fresh Google request each time — quota spent on
        // calls Google simply de-dupes. The window is shared with auto-index and the
        // PRO recrawl endpoint via _tamrank_index_requested_at.
        $cooldown_left = self::request_cooldown_remaining($post_id);
        if ($cooldown_left > 0) {
            wp_send_json_error(array(
                'message' => sprintf(
                    /* translators: %d: hours until indexing can be requested again */
                    tamrank__('idx_cooldown_active', 'Indexing was already requested for this page recently. Try again in about %d h — Google rarely acts faster.'),
                    max(1, (int) ceil($cooldown_left / HOUR_IN_SECONDS))
                ),
                'code' => 'cooldown_active',
            ), 429);
        }

        $api = TamRank_API_Client::get_instance();

        // Fire-and-forget: request indexing. The index-status display (rendered
        // result) is a PRO feature; FREE returns a confirmation toast only.
        $result = $api->request_indexing($url);
        if (is_wp_error($result)) {
            wp_send_json_error(array('message' => $result->get_error_message(), 'code' => $result->get_error_code() ?: 'request_failed'));
        }

        // Open the shared cooldown window so repeat clicks (and the other surfaces)
        // are refused until it lapses.
        update_post_meta($post_id, '_tamrank_index_requested_at', time());

        wp_send_json_success(array(
            'message' => sprintf(tamrank__('idx_requested_for', 'Indexing requested for %s'), $url),
        ));
    }

    /**
     * The term half of ajax_request_indexing(): resolve a term archive URL and
     * push it down the exact same request path a post takes.
     *
     * Gate order is deliberately tighter than the post branch: the capability is
     * checked before the property guard, so a user who may not edit this term
     * never learns anything about the connected Search Console property.
     *
     * Always terminates the request via wp_send_json_*.
     *
     * @return void
     */
    private function request_indexing_for_term()
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the nonce is verified by check_ajax_referer() in ajax_request_indexing(), the only caller; PHPCS cannot see across the method boundary.
        $term_id = intval($_POST['term_id'] ?? 0);
        $term    = $term_id ? get_term($term_id) : null;
        if (!$term || is_wp_error($term)) {
            wp_send_json_error(array(
                'message' => tamrank__('idx_term_not_found', 'This term could not be found.'),
                'code'    => 'not_found',
            ));
        }

        // Public taxonomies only — the term-level twin of the "public post type"
        // rule. A private taxonomy has no archive for Google to crawl.
        $taxonomy = get_taxonomy($term->taxonomy);
        if (!$taxonomy || !$taxonomy->public) {
            wp_send_json_error(array(
                'message' => tamrank__('idx_term_not_public', 'This taxonomy has no public archive to index.'),
                'code'    => 'not_public',
            ));
        }

        // Object-level authorization: may this user edit THIS term?
        if (!current_user_can('edit_term', $term->term_id)) {
            wp_send_json_error(array(
                'message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'),
                'code'    => 'permission_denied',
            ), 403);
        }

        // Same property guard as the post path: refuse rather than spend quota
        // on a property Google has no data for.
        if (self::has_property_mismatch()) {
            wp_send_json_error(array(
                'message' => tamrank__('idx_mismatch_no_data', 'Wrong GSC property connected. See banner above.'),
                'code'    => 'property_mismatch',
            ), 409);
        }

        $url = get_term_link($term);
        if (is_wp_error($url) || empty($url)) {
            wp_send_json_error(array(
                'message' => tamrank__('error_url_determine', 'Could not determine the URL.'),
                'code'    => 'not_found',
            ));
        }

        // One request per archive per window, keyed on the same meta name the
        // post surfaces use — in term-meta, so a term has its own window.
        $cooldown_left = self::request_cooldown_remaining($term->term_id, 'term');
        if ($cooldown_left > 0) {
            wp_send_json_error(array(
                'message' => sprintf(
                    /* translators: %d: hours until indexing can be requested again */
                    tamrank__('idx_cooldown_active', 'Indexing was already requested for this page recently. Try again in about %d h — Google rarely acts faster.'),
                    max(1, (int) ceil($cooldown_left / HOUR_IN_SECONDS))
                ),
                'code' => 'cooldown_active',
            ), 429);
        }

        $api    = TamRank_API_Client::get_instance();
        $result = $api->request_indexing($url);
        if (is_wp_error($result)) {
            wp_send_json_error(array(
                'message' => $result->get_error_message(),
                'code'    => $result->get_error_code() ?: 'request_failed',
            ));
        }

        update_term_meta($term->term_id, '_tamrank_index_requested_at', time());

        wp_send_json_success(array(
            'message' => sprintf(tamrank__('idx_requested_for', 'Indexing requested for %s'), $url),
        ));
    }

    // ── GSC property guard (shared with PRO) ─────────────────────

    /**
     * Check whether the connected GSC property does not match this site's host.
     *
     * Used to short-circuit index work that would never return data (auto-index
     * request, manual request, PRO column render / batch submission) when the
     * connected property belongs to a different domain — common when a user has
     * the wrong Google account connected or picks `sc-domain:foo` while the site
     * is `https://bar`. Public + static so the PRO add-on can reuse it.
     *
     * @return bool True if a property is connected but its host differs from site_url().
     */
    public static function has_property_mismatch()
    {
        if (!class_exists('TamRank_GSC_Connector')
            || !TamRank_GSC_Connector::is_connected()) {
            return false;
        }

        $property = TamRank_GSC_Connector::get_property();
        if (empty($property)) {
            return false;
        }

        $site_url = trailingslashit(site_url());
        $property_normalized = trailingslashit(preg_replace('#^sc-domain:#', 'https://', $property));
        $property_host = wp_parse_url($property_normalized, PHP_URL_HOST);
        if (empty($property_host)) {
            return false;
        }

        return stripos($site_url, $property_host) === false;
    }
}
