<?php
/**
 * AJAX Handler Class.
 *
 * Manages all AJAX functionality for Postomatic.
 *
 * @package Postomatic
 * @subpackage Core
 * @since   1.0.0
 */

namespace Postomatic\Core;

// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}


/**
 * Class Ajax_Handler.
 *
 * Handles all AJAX requests for the Postomatic plugin.
 */
class Ajax_Handler {

	/**
	 * Initialize the class and set up AJAX hooks.
	 *
	 * @return void
	 */
	public function init() {
		// Add initial check for admin-ajax.php - runs early in the request lifecycle.
		add_action( 'init', array( $this, 'check_admin_ajax' ), 1 );

		$this->register_hooks();
	}

	/**
	 * Perform initial check for admin-ajax.php requests.
	 *
	 * This method runs early in the AJAX request lifecycle to perform
	 * initial validation before any AJAX handlers are executed.
	 *
	 * @return void
	 */
	public function check_admin_ajax() {
		// Check if this is an admin-ajax.php request.
		if ( ! defined( 'DOING_AJAX' ) || ! DOING_AJAX ) {
			return;
		}

		$action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';

		// Only perform additional checks for Postomatic AJAX actions.
		if ( 0 !== strpos( $action, 'postomatic_' ) ) {
			return;
		}

		$nonce = isset( $_REQUEST['nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['nonce'] ) ) : '';

		/*
		 * This gate exists to reject requests that carry no valid plugin nonce
		 * at all. Which of the two nonces a given action requires is decided by
		 * that action's own handler, every one of which verifies its nonce and
		 * capability itself, so accepting either one here is sufficient.
		 *
		 * It previously tried to pick the right nonce from a hand-maintained
		 * list of action-name prefixes, defaulting to the support nonce for
		 * anything not listed. Any action missing from that list was therefore
		 * checked against the wrong nonce and killed outright before its handler
		 * ran. postomatic_handle_401 is exactly that case: it verifies the admin
		 * nonce and is sent the admin nonce, but was never added to the list, so
		 * this gate compared it against the support nonce and answered "Invalid
		 * request source". Since that action is the plugin's own recovery path
		 * for an expired API key, an expired key could not be renewed from the
		 * page that had just detected it — the request looked like it went
		 * through and the UI simply stopped making progress.
		 */
		$accepted_nonces = array( 'postomatic_admin_nonce', 'postomatic_support_nonce' );

		foreach ( $accepted_nonces as $nonce_action ) {
			if ( wp_verify_nonce( $nonce, $nonce_action ) ) {
				return;
			}
		}

		wp_send_json_error(
			array(
				'message' => 'Invalid request source',
			)
		);
		exit;
	}

	/**
	 * Register all AJAX hooks.
	 *
	 * @return void
	 */
	private function register_hooks() {
		/*
		 * All of these are admin-only actions, so only the logged-in variants
		 * are registered. The `wp_ajax_nopriv_` registrations that used to sit
		 * alongside them exposed the support ticket endpoints — which read and
		 * write against the account's support queue using the site's API key —
		 * to unauthenticated visitors. The one on get_attachment_image_html let
		 * anonymous callers render arbitrary attachment markup, including from
		 * private attachments.
		 */
		add_action( 'wp_ajax_get_attachment_image_html', array( $this, 'postomatic_get_attachment_image_html' ) );

		// Support ticket AJAX handlers.
		add_action( 'wp_ajax_postomatic_get_tickets', array( $this, 'get_support_tickets' ) );
		add_action( 'wp_ajax_postomatic_submit_ticket', array( $this, 'submit_support_ticket' ) );
		add_action( 'wp_ajax_postomatic_get_ticket', array( $this, 'get_single_support_ticket' ) );
	}

	/**
	 * Stop the request unless the current user may manage the plugin.
	 *
	 * A valid nonce only proves the request came from one of our pages; it says
	 * nothing about what the user is allowed to do. These handlers act on the
	 * account's own support queue, so they are limited to the same capability
	 * that guards the plugin's admin pages.
	 *
	 * @return void
	 */
	private function require_plugin_capability() {
		if ( ! current_user_can( \Postomatic\Constants::CAPABILITY ) ) {
			wp_send_json_error( array( 'message' => 'You do not have permission to perform this action.' ), 403 );
			wp_die();
		}
	}

	/**
	 * AJAX callback to get attachment image HTML using WordPress standards.
	 *
	 * @return void
	 */
	public function postomatic_get_attachment_image_html() {
		// Verify nonce.
		check_ajax_referer( 'get_attachment_image_html', '_ajax_nonce' );

		// Rendering attachment markup requires the same access as browsing the
		// media library.
		if ( ! current_user_can( 'upload_files' ) ) {
			wp_send_json_error( array( 'message' => 'You do not have permission to view this attachment.' ), 403 );
			wp_die();
		}

		if ( isset( $_POST['attachment_id'] ) && is_numeric( $_POST['attachment_id'] ) ) {
			$attachment_id = intval( wp_unslash( $_POST['attachment_id'] ) );
			$size          = isset( $_POST['size'] ) ? sanitize_text_field( wp_unslash( $_POST['size'] ) ) : 'thumbnail';

			$image_html = wp_get_attachment_image(
				$attachment_id,
				$size,
				false,
				array(
					'style' => 'max-width: 45px; height: 45px; margin-left: 4px; vertical-align: middle;',
				)
			);

			// Return image with proper headers.
			echo wp_kses_post( $image_html );
		} else {
			wp_send_json_error( 'Invalid attachment ID' );
		}

		wp_die();
	}

	/**
	 * Get all support tickets for the current user.
	 *
	 * @return void
	 */
	public function get_support_tickets() {
		// Verify nonce.
		check_ajax_referer( 'postomatic_support_nonce', 'nonce' );
		$this->require_plugin_capability();

		/*
		 * The API service key, like every other endpoint. These three support
		 * routes have moved inside the service's authenticated middleware group;
		 * it still accepts the activation `code` as an Api-Key for backward
		 * compatibility, but that is deprecated and due for removal, so they now
		 * send the same credential as the rest of the plugin.
		 */
		$api_key = \Postomatic\Utils::get_api_service_key();
		if ( empty( $api_key ) ) {
			wp_send_json_error( array( 'message' => 'API key not found' ) );
			wp_die();
		}

		// Make the API request.
		$api_url  = \Postomatic\Constants::API_BASE_URL_FRONT . '/api/services/postomatic/support';
		$response = wp_remote_get(
			$api_url,
			array(
				'headers' => array(
					'Api-Key'      => $api_key,
					'Content-Type' => 'application/json',
				),
			)
		);

		// Check for errors.
		if ( is_wp_error( $response ) ) {
			wp_send_json_error( array( 'message' => $response->get_error_message() ) );
			wp_die();
		}

		if ( $this->bail_on_error_response( $response ) ) {
			return;
		}

		// Get the response body.
		$body = wp_remote_retrieve_body( $response );
		$data = json_decode( $body, true );

		if ( ! is_array( $data ) ) {
			wp_send_json_error( array( 'message' => 'Unexpected response from the support service.' ), 502 );
			wp_die();
		}

		// Return the response. Keys are read defensively; the service is not
		// guaranteed to include 'plan' on every response.
		wp_send_json_success(
			array(
				'tickets' => $data['data'] ?? array(),
				'plan'    => $data['plan'] ?? '',
			)
		);
		wp_die();
	}

	/**
	 * Translate a support API error response into a JSON error and stop.
	 *
	 * The support routes now sit behind the service's authenticated middleware,
	 * which changed two outcomes the plugin used to see: a missing or invalid key
	 * is a 401 where it used to surface as a 500, and an unknown ticket id is a
	 * 404 where it used to be a 200 with an empty resource. Both were previously
	 * indistinguishable from a working response with no data.
	 *
	 * @param  array|\WP_Error $response Response from a wp_remote_* call.
	 * @return bool True when the request was answered and processing should stop.
	 */
	private function bail_on_error_response( $response ) {
		$code = (int) wp_remote_retrieve_response_code( $response );

		if ( $code < 400 ) {
			return false;
		}

		if ( 429 === $code ) {
			// Throttled, not broken. Tell the caller how long to wait rather than
			// letting the UI retry immediately.
			$retry_after = \Postomatic\Utils::get_retry_after_seconds( $response );

			wp_send_json_error(
				array(
					'message'     => sprintf(
						/* translators: %d: seconds to wait. */
						esc_html__( 'Too many requests. Please try again in %d seconds.', 'postomatic' ),
						$retry_after
					),
					'retry_after' => $retry_after,
				),
				429
			);
			wp_die();
		}

		$decoded = json_decode( wp_remote_retrieve_body( $response ), true );
		$message = is_array( $decoded ) && ! empty( $decoded['message'] ) ? $decoded['message'] : '';

		if ( 401 === $code ) {
			$message = $message ? $message : esc_html__( 'Your Postomatic connection needs to be renewed.', 'postomatic' );
		} elseif ( 404 === $code ) {
			$message = $message ? $message : esc_html__( 'That support ticket could not be found.', 'postomatic' );
		} elseif ( '' === $message ) {
			$message = esc_html__( 'The support service could not complete the request.', 'postomatic' );
		}

		wp_send_json_error( array( 'message' => $message ), $code );
		wp_die();
	}

	/**
	 * Submit a new support ticket.
	 *
	 * @return void
	 */
	public function submit_support_ticket() {
		// Verify nonce.
		check_ajax_referer( 'postomatic_support_nonce', 'nonce' );
		$this->require_plugin_capability();

		/*
		 * The API service key, like every other endpoint. These three support
		 * routes have moved inside the service's authenticated middleware group;
		 * it still accepts the activation `code` as an Api-Key for backward
		 * compatibility, but that is deprecated and due for removal, so they now
		 * send the same credential as the rest of the plugin.
		 */
		$api_key = \Postomatic\Utils::get_api_service_key();
		if ( empty( $api_key ) ) {
			wp_send_json_error( array( 'message' => 'API key not found' ) );
			wp_die();
		}

		// Get the form data.
		$title         = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
		$description   = isset( $_POST['description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['description'] ) ) : '';
		$high_priority = isset( $_POST['high_priority'] ) ? (bool) $_POST['high_priority'] : false;

		// Validate the form data.
		if ( empty( $title ) || empty( $description ) ) {
			wp_send_json_error( array( 'message' => 'Title and description are required' ) );
			wp_die();
		}

		// Prepare the data for the API request.
		$data = array(
			'title'         => $title,
			'description'   => $description,
			'high_priority' => $high_priority,
		);

		// Make the API request.
		$api_url  = \Postomatic\Constants::API_BASE_URL_FRONT . '/api/services/postomatic/support';
		$response = wp_remote_post(
			$api_url,
			array(
				'headers' => array(
					'Api-Key'      => $api_key,
					'Content-Type' => 'application/json',
				),
				'body'    => wp_json_encode( $data ),
			)
		);

		// Check for errors.
		if ( is_wp_error( $response ) ) {
			wp_send_json_error( array( 'message' => $response->get_error_message() ) );
			wp_die();
		}

		if ( $this->bail_on_error_response( $response ) ) {
			return;
		}

		// Get the response body.
		$body = wp_remote_retrieve_body( $response );
		$data = json_decode( $body, true );

		// Return the response.
		wp_send_json_success( $data );
		wp_die();
	}

	/**
	 * Get a single support ticket by ID.
	 *
	 * @return void
	 */
	public function get_single_support_ticket() {
		// Verify nonce.
		check_ajax_referer( 'postomatic_support_nonce', 'nonce' );
		$this->require_plugin_capability();

		/*
		 * The API service key, like every other endpoint. These three support
		 * routes have moved inside the service's authenticated middleware group;
		 * it still accepts the activation `code` as an Api-Key for backward
		 * compatibility, but that is deprecated and due for removal, so they now
		 * send the same credential as the rest of the plugin.
		 */
		$api_key = \Postomatic\Utils::get_api_service_key();
		if ( empty( $api_key ) ) {
			wp_send_json_error( array( 'message' => 'API key not found' ) );
			wp_die();
		}

		// Get the ticket ID from either GET or POST request.
		$ticket_id = '';
		if ( isset( $_GET['ticket_id'] ) ) {
			$ticket_id = sanitize_text_field( wp_unslash( $_GET['ticket_id'] ) );
		} elseif ( isset( $_POST['ticket_id'] ) ) {
			$ticket_id = sanitize_text_field( wp_unslash( $_POST['ticket_id'] ) );
		}

		if ( empty( $ticket_id ) ) {
			wp_send_json_error( array( 'message' => 'Ticket ID is required' ) );
			wp_die();
		}

		/*
		 * The ticket id is now matched exactly rather than with LIKE, and it is
		 * URL-encoded so a value containing a slash or a reserved character
		 * addresses the intended ticket instead of a different path.
		 */
		$api_url  = \Postomatic\Constants::API_BASE_URL_FRONT . '/api/services/postomatic/support/' . rawurlencode( $ticket_id );
		$response = wp_remote_get(
			$api_url,
			array(
				'headers' => array(
					'Api-Key'      => $api_key,
					'Content-Type' => 'application/json',
				),
			)
		);

		// Check for errors.
		if ( is_wp_error( $response ) ) {
			wp_send_json_error( array( 'message' => $response->get_error_message() ) );
			wp_die();
		}

		if ( $this->bail_on_error_response( $response ) ) {
			return;
		}

		// Get the response body.
		$body = wp_remote_retrieve_body( $response );
		$data = json_decode( $body, true );

		// A missing ticket is a 404 now, so anything reaching here should have a
		// resource; the key is still read defensively.
		if ( ! is_array( $data ) || ! isset( $data['data'] ) ) {
			wp_send_json_error( array( 'message' => esc_html__( 'That support ticket could not be found.', 'postomatic' ) ), 404 );
			wp_die();
		}

		// Return the response.
		wp_send_json_success( $data['data'] );
		wp_die();
	}
}
