<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Rest;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.Security.NonceVerification.Recommended
// phpcs:disable WordPress.Security.NonceVerification.Missing
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash
// $_SERVER values (REMOTE_ADDR) are read for the rate limit; nonce is
// verified manually inside create() because anonymous REST callers don't
// trigger WP's cookie-auth nonce check automatically.
use PrettyLinks\Options\Store as LiteOptionsStore;
use PrettyLinks\Pro\Options\Store as OptionsStore;
use PrettyLinks\Repositories\Links;
use PrettyLinks\Rest\Controllers\BaseController;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * REST surface for the legacy `[prli_create_form]` shortcode. The form
 * POSTs an HTML body to this route; the callback creates the link and
 * issues a `wp_safe_redirect()` so the visitor lands on the configured
 * display page (or back on the form page with `?slug=…`).
 *
 * Anonymous-friendly — `permission_callback` is `__return_true`. CSRF
 * protection comes from a nonce field embedded in the form, verified
 * manually here. Rate-limited per-IP (10 creations / hour) via a
 * transient.
 *
 * Replaces the v3 `wp_ajax_plp-create-public-link` admin-ajax endpoint.
 * The route is gated by the same `allow_public_link_creation` Pro option
 * as the shortcode itself; flipping that off makes the route 404 (well,
 * the option check redirects to home and exits before WP REST builds a
 * response, which is intentional — we mirror the v3 behaviour where the
 * endpoint silently bounces when the feature is off).
 */
final class PublicLinksController extends BaseController
{
    public const NONCE_ACTION = 'plp-create-public-link';
    // Custom field name — see Service::renderForm() for why this can't
    // be `_wpnonce` (WP REST cookie-auth would intercept it).
    public const NONCE_FIELD = 'prli_public_nonce';

    /**
     * Register the public link creation REST route.
     *
     * @return void
     */
    public function register(): void
    {
        register_rest_route($this->namespace(), '/pro/public-links', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'create'],
                'permission_callback' => '__return_true',
            ],
        ]);
    }

    /**
     * Public link creation. Anonymous-allowed; redirects on completion.
     *
     * The callback never returns a JSON response — it `exit`s after a
     * `wp_safe_redirect()` so the form-submission UX matches v3.
     *
     * @param WP_REST_Request $request Incoming REST request.
     *
     * @return void
     */
    public function create(WP_REST_Request $request): void
    {
        if (!$this->isEnabled()) {
            wp_safe_redirect(home_url('/'));
            exit;
        }

        $body     = $request->get_body_params();
        $referral = isset($body['referral-url']) && is_string($body['referral-url'])
            ? esc_url_raw(trim(wp_unslash((string) $body['referral-url'])))
            : home_url('/');

        // The wp_create_nonce() / wp_verify_nonce() calls both key on the
        // current user ID + session token. WP REST's rest_cookie_check_errors resets
        // the current user to 0 when no `_wpnonce` is supplied
        // (wp-includes/rest-api.php) — so a form rendered for a
        // logged-in user (nonce keyed to user N) would never validate
        // here (verifies as user 0). Re-hydrate the user from the
        // LOGGED_IN cookie so the same user ID hashes both sides.
        // Anonymous visitors have no cookie and stay at user 0, which
        // also matches the nonce keyed to 0 at render time.
        if (!is_user_logged_in()) {
            $cookieUserId = wp_validate_auth_cookie('', 'logged_in');
            if ($cookieUserId) {
                wp_set_current_user((int) $cookieUserId);
            }
        }

        $nonce = isset($body[self::NONCE_FIELD]) ? (string) $body[self::NONCE_FIELD] : '';
        if ($nonce === '' || !wp_verify_nonce($nonce, self::NONCE_ACTION)) {
            $this->bounceBack($referral, [__('Security check failed.', 'pretty-link-pro')], '');
        }

        if (!$this->checkRateLimit()) {
            $this->bounceBack($referral, [__('Too many requests. Please try again later.', 'pretty-link-pro')], '');
        }

        // Validate on the raw input, BEFORE esc_url_raw() — that helper
        // silently prepends `http://` when no scheme is present, which
        // would let bare junk like "not-a-url" slip through.
        $rawUrl = isset($body['url']) && is_string($body['url'])
            ? trim(wp_unslash((string) $body['url']))
            : '';
        if ($rawUrl === '' || !preg_match('~^https?://[^\s/$.?\#][^\s]*$~i', $rawUrl)) {
            $this->bounceBack($referral, [__('Please enter a valid URL.', 'pretty-link-pro')], $rawUrl);
        }
        $url = esc_url_raw($rawUrl);
        if ($url === '') {
            $this->bounceBack($referral, [__('Please enter a valid URL.', 'pretty-link-pro')], $rawUrl);
        }

        $lite         = new LiteOptionsStore();
        $pro          = new OptionsStore();
        $redirectType = isset($body['redirect_type']) ? sanitize_key(wp_unslash((string) $body['redirect_type'])) : '-1';
        if ($redirectType === '-1' || $redirectType === '') {
            $redirectType = (string) $lite->get('link_redirect_type', '302');
        }

        $track = isset($body['track']) ? (int) wp_unslash((string) $body['track']) : -1;
        if ($track === -1) {
            $track = !empty($lite->get('link_track_me', true)) ? 1 : 0;
        }

        // Anonymous visitors don't supply a name. Omitting it from the
        // payload triggers Links::create()'s v3-parity path: fetch
        // <title> from the target URL, falling back to the slug.
        $payload = [
            'url'           => $url,
            'source'        => 'public',
            'redirect_type' => $redirectType,
            'track_me'      => (bool) $track,
            'nofollow'      => (bool) $lite->get('link_nofollow', true),
            'sponsored'     => (bool) $lite->get('link_sponsored', false),
        ];

        $result = $this->links()->create($payload);
        if (isset($result['error'])) {
            $this->bounceBack($referral, [$this->translateError((string) $result['error'])], $url);
        }

        $link = is_array($result) ? $result : null;
        if (!$link || empty($link['slug'])) {
            $this->bounceBack($referral, [__('Unable to create the link.', 'pretty-link-pro')], $url);
        }

        $slug        = (string) $link['slug'];
        $displayPage = (int) $pro->get('public_link_display_page', 0);
        if ($displayPage > 0) {
            $page = get_permalink($displayPage);
            if (is_string($page) && $page !== '') {
                wp_safe_redirect(add_query_arg(['slug' => rawurlencode($slug)], $page));
                exit;
            }
        }

        // Fallback: redirect back to the referral page with ?slug=… so the
        // display shortcodes there (if any) pick up the new link.
        wp_safe_redirect(add_query_arg(['slug' => rawurlencode($slug)], $referral));
        exit;
    }

    /**
     * Redirect back to the form with error context, then exit.
     *
     * @param string   $referral Referral URL to bounce back to.
     * @param string[] $errors   Error codes to surface on the form.
     * @param string   $url      Submitted target URL to repopulate.
     *
     * phpcs:ignore Squiz.Commenting.FunctionComment.InvalidNoReturn -- Always exits via wp_safe_redirect; return type kept for strict callable validation.
     * @return WP_REST_Response Returned only as a sentinel — `create()`
     *                          always exits before the REST server gets
     *                          to serialise this. Kept so PHP's strict
     *                          callable validation doesn't complain.
     */
    private function bounceBack(string $referral, array $errors, string $url): WP_REST_Response
    {
        $args = ['errors' => array_map('rawurlencode', $errors)];
        if ($url !== '') {
            $args['url'] = rawurlencode($url);
        }
        wp_safe_redirect(wp_validate_redirect(add_query_arg($args, $referral), home_url('/')));
        exit;
    }

    /**
     * Map an internal error code to a user-facing message.
     *
     * @param string $code Internal error code.
     *
     * @return string
     */
    private function translateError(string $code): string
    {
        $map = [
            'url_required'  => __('Target URL is required.', 'pretty-link-pro'),
            'slug_in_use'   => __('That custom slug is already taken.', 'pretty-link-pro'),
            'slug_reserved' => __('That custom slug is reserved.', 'pretty-link-pro'),
            'insert_failed' => __('Unable to save the link. Please try again.', 'pretty-link-pro'),
        ];
        return $map[$code] ?? __('Unable to create the link.', 'pretty-link-pro');
    }

    /**
     * Whether public link creation is enabled in options.
     *
     * @return boolean
     */
    private function isEnabled(): bool
    {
        return (bool) (new OptionsStore())->get('allow_public_link_creation', false);
    }

    /**
     * Transient-based rate limit: max 10 creations per IP per hour.
     */
    private function checkRateLimit(): bool
    {
        $ip    = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
        $key   = 'prli_pub_link_' . md5($ip);
        $count = (int) get_transient($key);
        if ($count >= 10) {
            return false;
        }
        set_transient($key, $count + 1, HOUR_IN_SECONDS);
        return true;
    }

    /**
     * Resolve the links repository from the container.
     *
     * @return Links
     */
    private function links(): Links
    {
        return $this->container->get(Links::class);
    }
}
