<?php
/**
 * Request a Review prompt service.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Review;

use NimblyMail\SMTP\Audit\AuditResult;
use NimblyMail\SMTP\Audit\HealthAuditor;
use NimblyMail\SMTP\Log\Model;

defined( 'ABSPATH' ) || exit;

/**
 * Class ReviewRequest
 *
 * Politely asks site administrators to leave a review of NimblyMail SMTP once
 * they have actually had value from it. The first prompt is armed when the
 * install has proven itself — past a short tenure floor, with enough successful
 * sends, and not during a failure/red-deliverability spell — or, for low-volume
 * sites, after a time fallback. From there each "Remind me later" repeats on an
 * exponential back-off (intervals doubling: 14 -> 28 -> 56 -> 112 days) for a
 * maximum of 5 notices, after which it retires permanently.
 *
 * The prompt renders as a single, non-blocking WordPress admin notice on every
 * admin screen — deliberately not a modal, so the ask sits beside the user's
 * work rather than interrupting it. All dismissal state is tracked per user
 * (user meta) so one administrator's choice does not affect another. The first
 * notice is shown immediately once armed; each subsequent (snoozed) notice is
 * scheduled per user via a single Action Scheduler action.
 *
 * @since 1.0.0
 */
final class ReviewRequest {

    /**
     * Action Scheduler hook that arms the prompt for a user.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const AS_HOOK = 'nimblymail_review_request_show';

    /**
     * Action Scheduler group (shared with the plugin's other actions).
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const AS_GROUP = 'nimblymail-smtp';

    /**
     * User meta key holding the per-user review state array.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const META_KEY = 'nimblymail_review_request';

    /**
     * Option storing the activation timestamp (Unix seconds).
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const OPTION_INSTALLED = 'nimblymail_review_installed_at';

    /**
     * Back-off base for snoozed notices, in days (snooze interval = base * 2^count).
     *
     * @since 1.0.0
     *
     * @var int
     */
    public const BASE_DELAY_DAYS = 7;

    /**
     * Maximum number of notices shown to a user before retiring the prompt.
     *
     * @since 1.0.0
     *
     * @var int
     */
    public const MAX_NOTICES = 5;

    /**
     * Minimum days since activation before the prompt can arm.
     *
     * @since 1.0.0
     *
     * @var int
     */
    public const MIN_TENURE_DAYS = 3;

    /**
     * Minimum successful sends before the prompt arms on proven value.
     *
     * @since 1.0.0
     *
     * @var int
     */
    public const MIN_SENT = 25;

    /**
     * Days after which a low-volume site is armed regardless of send count.
     *
     * @since 1.0.0
     *
     * @var int
     */
    public const FALLBACK_DAYS = 30;

    /**
     * Default wordpress.org reviews URL.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const REVIEW_URL = 'https://wordpress.org/support/plugin/nimblymail-smtp/reviews/#new-post';

    /**
     * State value: armed/scheduled, not yet shown.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const STATUS_PENDING = 'pending';

    /**
     * State value: user clicked "Remind me later"; next action scheduled.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const STATUS_SNOOZED = 'snoozed';

    /**
     * State value: terminal — never shown or scheduled again.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const STATUS_DISMISSED = 'dismissed';

    /**
     * Optional override for the external-signals reader (tests inject this to
     * bypass the static `Model::*` calls and the health snapshot lookup).
     *
     * @since 1.0.0
     *
     * @var \Closure|null
     */
    private ?\Closure $signals_reader;

    /**
     * Constructor.
     *
     * @since 1.0.0
     *
     * @param \Closure|null $signals_reader Optional closure returning
     *                                      `{ sent_total: int, has_recent_fails: bool,
     *                                      health_is_red: bool }`. When null, the
     *                                      defaults read from `Log\Model` and the
     *                                      `Audit\HealthAuditor` snapshot option.
     */
    public function __construct( ?\Closure $signals_reader = null ) {
        $this->signals_reader = $signals_reader;
    }

    /**
     * Wire hooks.
     *
     * The Action Scheduler callback is registered unconditionally so it fires
     * in cron/loopback context. The admin-only hooks are harmless outside the
     * admin because `admin_init` / `admin_notices` / `network_admin_notices`
     * never fire there.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function init(): void {
        add_action( self::AS_HOOK, array( $this, 'handle_show_action' ) );
        add_action( 'admin_init', array( $this, 'maybe_arm_current_user' ) );
        add_action( 'admin_init', array( $this, 'handle_notice_action' ) );
        add_action( 'admin_notices', array( $this, 'render_admin_notice' ) );
        add_action( 'network_admin_notices', array( $this, 'render_admin_notice' ) );
    }

    /**
     * Record the activation timestamp so the tenure clock starts on activation.
     *
     * The per-user Action Scheduler action is NOT created here: an activation
     * callback runs after `init` has already fired in that request, before
     * Action Scheduler's data store is initialised, so scheduling now would
     * trip its `_doing_it_wrong` guard. Each administrator is instead armed on
     * their next admin page load via {@see maybe_arm_current_user()}, where the
     * data store is guaranteed ready. The timestamp is written once and never
     * reset on re-activation, so dismissal progress survives a reactivate.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public static function schedule_on_activation(): void {
        if ( (int) get_option( self::OPTION_INSTALLED, 0 ) <= 0 ) {
            update_option( self::OPTION_INSTALLED, time(), false );
        }
    }

    /**
     * Cancel all review prompt actions on deactivation.
     *
     * Targets only the review hook so the plugin's other scheduled actions
     * (log rotation, health audits) are unaffected. Per-user meta and the
     * activation timestamp are preserved so dismissal choices survive a
     * deactivate/reactivate cycle.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public static function unschedule(): void {
        if ( function_exists( 'as_unschedule_all_actions' ) ) {
            as_unschedule_all_actions( self::AS_HOOK, array(), self::AS_GROUP );
        }
    }

    /**
     * Action Scheduler callback. Flips the per-user "show" flag when the timer
     * elapses, or retires the prompt once the notice budget is exhausted.
     *
     * @since 1.0.0
     *
     * @param int $user_id The user the action was scheduled for.
     *
     * @return void
     */
    public function handle_show_action( $user_id ): void {
        $user_id = (int) $user_id;
        if ( $user_id <= 0 ) {
            return;
        }

        $state = $this->get_state( $user_id );
        if ( self::STATUS_DISMISSED === $state['status'] ) {
            return;
        }

        ++$state['count'];

        if ( $state['count'] > $this->max_notices() ) {
            $state['status'] = self::STATUS_DISMISSED;
            $state['show']   = false;
            $this->set_state( $user_id, $state );
            return;
        }

        $state['show'] = true;
        $this->set_state( $user_id, $state );
    }

    /**
     * Arm the current administrator once the success conditions are met.
     *
     * Runs on every admin page load for admins with no review state yet. When
     * {@see should_arm()} passes, the first notice is shown immediately (no
     * Action Scheduler round-trip — only later, snoozed notices are scheduled).
     * The success-condition evaluation is throttled to once per day per user so
     * un-armed admins do not run the stats queries on every request. Covers
     * administrators created after activation and every site on a network.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function maybe_arm_current_user(): void {
        if ( ! $this->is_enabled() || ! current_user_can( 'manage_options' ) ) {
            return;
        }

        $user_id = get_current_user_id();
        if ( $user_id <= 0 ) {
            return;
        }

        $state = get_user_meta( $user_id, self::META_KEY, true );
        if ( is_array( $state ) && ! empty( $state ) ) {
            return;
        }

        $throttle_key = 'nimblymail_review_check_' . $user_id;
        if ( get_transient( $throttle_key ) ) {
            return;
        }

        if ( ! $this->should_arm() ) {
            set_transient( $throttle_key, 1, DAY_IN_SECONDS );
            return;
        }

        // Conditions met — surface the first notice on this page load.
        $this->set_state(
            $user_id,
            array(
                'status' => self::STATUS_PENDING,
                'count'  => 1,
                'show'   => true,
            )
        );
    }

    /**
     * Whether the success conditions for arming the prompt are met.
     *
     * Arms when the install has proven value — past the tenure floor, with at
     * least MIN_SENT successful sends — or, for low-volume sites with at least
     * one successful send, after the time fallback. Never arms during a "bad
     * moment" (recent send failures or a red deliverability audit), so the ask
     * only reaches happy customers.
     *
     * @since 1.0.0
     *
     * @return bool True when the prompt may be armed for the current site/user.
     */
    private function should_arm(): bool {
        $installed_at = (int) get_option( self::OPTION_INSTALLED, 0 );
        if ( $installed_at <= 0 ) {
            // Start the clock the first time we evaluate in this context.
            $installed_at = time();
            update_option( self::OPTION_INSTALLED, $installed_at, false );
        }

        $tenure = time() - $installed_at;
        if ( $tenure < $this->min_tenure() ) {
            return false;
        }

        $signals = $this->read_signals();
        if ( $signals['has_recent_fails'] || $signals['health_is_red'] ) {
            return false;
        }

        $sent = (int) $signals['sent_total'];
        if ( $sent >= $this->min_sent() ) {
            return true;
        }

        // Low-volume fallback — but never ask a site that has produced no
        // successful sends at all; it has had no value worth reviewing.
        return $sent > 0 && $tenure >= $this->fallback_delay();
    }

    /**
     * Read the external signals that drive `should_arm()`.
     *
     * Default reads all-time and 7-day send counts from the logs table and the
     * cached deliverability snapshot from options. Tests inject a closure via
     * the constructor to substitute deterministic values without touching the
     * static `Log\Model` calls or `$wpdb`.
     *
     * @since 1.0.0
     *
     * @return array{sent_total: int, has_recent_fails: bool, health_is_red: bool}
     */
    private function read_signals(): array {
        if ( null !== $this->signals_reader ) {
            return ( $this->signals_reader )();
        }

        $snapshot = get_option( HealthAuditor::OPTION_SNAPSHOT, null );

        return array(
            'sent_total'       => (int) ( Model::get_stats()['sent'] ?? 0 ),
            'has_recent_fails' => (int) ( Model::get_dashboard_stats( 7 )['failed'] ?? 0 ) > 0,
            'health_is_red'    => is_array( $snapshot )
                && isset( $snapshot['composite'] )
                && AuditResult::COMPOSITE_RED === $snapshot['composite'],
        );
    }

    /**
     * Handle the admin-notice action links (snooze / dismiss / leave review).
     *
     * Nonce-protected GET links route back here; "reviewed" redirects out to
     * wordpress.org while the others return the admin to the originating page.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function handle_notice_action(): void {
        if ( ! isset( $_GET['nimblymail_review_action'] ) || ! current_user_can( 'manage_options' ) ) {
            return;
        }

        check_admin_referer( 'nimblymail_review' );

        $action = sanitize_key( wp_unslash( $_GET['nimblymail_review_action'] ) );
        if ( ! in_array( $action, array( 'snooze', 'forever', 'reviewed' ), true ) ) {
            return;
        }

        $this->transition( $action );

        if ( 'reviewed' === $action ) {
            // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Intentional external redirect to the wp.org review page.
            wp_redirect( $this->get_review_url() );
            exit;
        }

        wp_safe_redirect( remove_query_arg( array( 'nimblymail_review_action', '_wpnonce' ) ) );
        exit;
    }

    /**
     * Render the review request admin notice.
     *
     * Shown on every admin screen (including the plugin's own pages) as a
     * single, non-blocking notice. "Leave a review" opens wordpress.org in a
     * new tab — routed through the nonce handler so the prompt is also marked
     * done — without navigating the current tab away. "Remind me later" and
     * "Don't show again" reload the current page via their nonce links. No
     * JavaScript is required; the layout separates the permanent-dismiss link
     * (left) from the action buttons (right).
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function render_admin_notice(): void {
        if ( ! $this->should_display() ) {
            return;
        }

        $review_link  = wp_nonce_url(
            add_query_arg( array( 'nimblymail_review_action' => 'reviewed' ) ),
            'nimblymail_review'
        );
        $snooze_link  = wp_nonce_url(
            add_query_arg( array( 'nimblymail_review_action' => 'snooze' ) ),
            'nimblymail_review'
        );
        $forever_link = wp_nonce_url(
            add_query_arg( array( 'nimblymail_review_action' => 'forever' ) ),
            'nimblymail_review'
        );

        echo '<div class="notice notice-info nimblymail-review-notice">';
        echo '<p style="margin-bottom: 4px;"><strong>' . esc_html__( 'Enjoying NimblyMail SMTP?', 'nimblymail-smtp' ) . '</strong></p>';
        echo '<p style="margin-top: 0;">' . esc_html( $this->get_message() ) . '</p>';
        echo '<p style="display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;">';
        echo '<a href="' . esc_url( $forever_link ) . '">';
        echo esc_html__( "Don't show again", 'nimblymail-smtp' );
        echo '</a>';
        echo '<span>';
        echo '<a href="' . esc_url( $review_link ) . '" class="button button-primary" target="_blank" rel="noopener">';
        echo esc_html__( 'Leave a review', 'nimblymail-smtp' ) . ' <span aria-hidden="true">&#8599;</span>';
        echo '</a> ';
        echo '<a href="' . esc_url( $snooze_link ) . '" class="button" style="margin-left: 6px;">';
        echo esc_html__( 'Remind me later', 'nimblymail-smtp' );
        echo '</a>';
        echo '</span>';
        echo '</p>';
        echo '</div>';
    }

    /**
     * Whether the prompt should be shown to the current user right now.
     *
     * @since 1.0.0
     *
     * @return bool True when the prompt is eligible to display.
     */
    public function should_display(): bool {
        if ( ! $this->is_enabled() || ! current_user_can( 'manage_options' ) ) {
            return false;
        }

        $state = $this->get_state( get_current_user_id() );

        if ( self::STATUS_DISMISSED === $state['status'] || $state['count'] > $this->max_notices() ) {
            return false;
        }

        return ! empty( $state['show'] );
    }

    /**
     * Apply a user action to the current user's review state.
     *
     * @since 1.0.0
     *
     * @param string   $action  One of 'snooze', 'forever', 'reviewed'.
     * @param int|null $user_id Optional explicit user; defaults to current user.
     *
     * @return array{status: string, count: int, show: bool} The updated state.
     */
    public function transition( string $action, ?int $user_id = null ): array {
        $user_id = $user_id ?? get_current_user_id();
        $state   = $this->get_state( $user_id );

        if ( 'snooze' === $action ) {
            $state['show'] = false;
            if ( $state['count'] >= $this->max_notices() ) {
                $state['status'] = self::STATUS_DISMISSED;
                $this->unschedule_user( $user_id );
            } else {
                $state['status'] = self::STATUS_SNOOZED;
                $this->schedule_next( $user_id, $state['count'] );
            }
        } else {
            // 'forever' and 'reviewed' both retire the prompt permanently.
            $state['status'] = self::STATUS_DISMISSED;
            $state['show']   = false;
            $this->unschedule_user( $user_id );
        }

        $this->set_state( $user_id, $state );

        return $state;
    }

    /**
     * Schedule the next prompt for a user after a snooze.
     *
     * The interval doubles with each notice already shown:
     * `base_delay * 2^count` (count = notices shown so far).
     *
     * @since 1.0.0
     *
     * @param int $user_id The user to reschedule.
     * @param int $count   Notices already shown to the user.
     *
     * @return void
     */
    private function schedule_next( int $user_id, int $count ): void {
        if ( ! function_exists( 'as_schedule_single_action' ) ) {
            return;
        }

        $this->unschedule_user( $user_id );

        $interval = $this->base_delay() * ( 2 ** $count );
        as_schedule_single_action( time() + $interval, self::AS_HOOK, array( 'user_id' => $user_id ), self::AS_GROUP );
    }

    /**
     * Cancel a single user's pending review action.
     *
     * @since 1.0.0
     *
     * @param int $user_id The user whose action to cancel.
     *
     * @return void
     */
    private function unschedule_user( int $user_id ): void {
        if ( function_exists( 'as_unschedule_all_actions' ) ) {
            as_unschedule_all_actions( self::AS_HOOK, array( 'user_id' => $user_id ), self::AS_GROUP );
        }
    }

    /**
     * Read a user's review state with defaults applied.
     *
     * @since 1.0.0
     *
     * @param int $user_id The user to read.
     *
     * @return array{status: string, count: int, show: bool}
     */
    private function get_state( int $user_id ): array {
        $state = get_user_meta( $user_id, self::META_KEY, true );
        if ( ! is_array( $state ) ) {
            $state = array();
        }

        $state = wp_parse_args(
            $state,
            array(
                'status' => self::STATUS_PENDING,
                'count'  => 0,
                'show'   => false,
            )
        );

        $state['count'] = (int) $state['count'];
        $state['show']  = (bool) $state['show'];

        return $state;
    }

    /**
     * Persist a user's review state.
     *
     * @since 1.0.0
     *
     * @param int                                           $user_id The user to write.
     * @param array{status: string, count: int, show: bool} $state   The state to store.
     *
     * @return void
     */
    private function set_state( int $user_id, array $state ): void {
        update_user_meta( $user_id, self::META_KEY, $state );
    }

    /**
     * Whether the review prompt feature is enabled.
     *
     * @since 1.0.0
     *
     * @return bool
     */
    private function is_enabled(): bool {
        /**
         * Filters whether the Request a Review prompt is enabled.
         *
         * @since 1.0.0
         *
         * @param bool $enabled Default true.
         */
        return (bool) apply_filters( 'nimblymail_review_request_enabled', true );
    }

    /**
     * Back-off base (seconds) for snoozed notices; the snooze interval is
     * `base_delay * 2^count`.
     *
     * @since 1.0.0
     *
     * @return int
     */
    private function base_delay(): int {
        /**
         * Filters the back-off base (seconds) for snoozed review notices.
         *
         * @since 1.0.0
         *
         * @param int $seconds Default 7 days.
         */
        return (int) apply_filters( 'nimblymail_review_request_base_delay', self::BASE_DELAY_DAYS * DAY_IN_SECONDS );
    }

    /**
     * Maximum number of notices to show a user.
     *
     * @since 1.0.0
     *
     * @return int
     */
    private function max_notices(): int {
        /**
         * Filters the maximum number of review notices shown per user.
         *
         * @since 1.0.0
         *
         * @param int $max Default 5.
         */
        return (int) apply_filters( 'nimblymail_review_request_max_notices', self::MAX_NOTICES );
    }

    /**
     * Minimum tenure (seconds since activation) before the prompt can arm.
     *
     * @since 1.0.0
     *
     * @return int
     */
    private function min_tenure(): int {
        /**
         * Filters the minimum time since activation before arming the prompt.
         *
         * @since 1.0.0
         *
         * @param int $seconds Default 3 days.
         */
        return (int) apply_filters( 'nimblymail_review_request_min_tenure', self::MIN_TENURE_DAYS * DAY_IN_SECONDS );
    }

    /**
     * Minimum successful sends before the prompt arms on proven value.
     *
     * @since 1.0.0
     *
     * @return int
     */
    private function min_sent(): int {
        /**
         * Filters the successful-send threshold for arming the prompt.
         *
         * @since 1.0.0
         *
         * @param int $count Default 25.
         */
        return (int) apply_filters( 'nimblymail_review_request_min_sent', self::MIN_SENT );
    }

    /**
     * Time fallback (seconds) after which a low-volume site is armed regardless
     * of send count.
     *
     * @since 1.0.0
     *
     * @return int
     */
    private function fallback_delay(): int {
        /**
         * Filters the low-volume time fallback for arming the prompt.
         *
         * @since 1.0.0
         *
         * @param int $seconds Default 30 days.
         */
        return (int) apply_filters( 'nimblymail_review_request_fallback_delay', self::FALLBACK_DAYS * DAY_IN_SECONDS );
    }

    /**
     * The wordpress.org reviews URL.
     *
     * @since 1.0.0
     *
     * @return string
     */
    private function get_review_url(): string {
        /**
         * Filters the wordpress.org reviews URL used by the prompt.
         *
         * @since 1.0.0
         *
         * @param string $url Default plugin reviews URL.
         */
        return (string) apply_filters( 'nimblymail_review_request_url', self::REVIEW_URL );
    }

    /**
     * The prompt message body.
     *
     * @since 1.0.0
     *
     * @return string
     */
    private function get_message(): string {
        $default = __( "If NimblyMail is keeping your site's email flowing, a quick review would mean a lot — and helps other site owners find it.", 'nimblymail-smtp' );

        /**
         * Filters the review prompt message body.
         *
         * @since 1.0.0
         *
         * @param string $message Default message.
         */
        return (string) apply_filters( 'nimblymail_review_request_message', $default );
    }
}
