<?php
/**
 * File to handle ajax requests.
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly
}

/**
 * Class to register admin ajax.
 */
class SimpleStats_Admin_Ajax {

	/**
	 * Hold the instance.
	 *
	 * @var self
	 */
	private static $instance;

	/**
	 * Get instance of class.
	 *
	 * @return self
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Constructor function of the class.
	 */
	private function __construct() {
		add_action( 'wp_ajax_simplestats_activate_plugin', array( $this, 'activate_plugin' ) );
		add_action( 'wp_ajax_simplestats_install_plugin', 'wp_ajax_install_plugin' );
		add_action( 'wp_ajax_simplestats_get_notifications', array( $this, 'get_notifications' ) );
		add_action( 'wp_ajax_simplestats_dismiss_notification', array( $this, 'dismiss_notification' ) );
		add_action( 'wp_ajax_simplestats_get_addons', array( $this, 'get_addons' ) );
		add_action( 'wp_ajax_simplestats_get_report', array( $this, 'get_report' ) );
		add_action( 'wp_ajax_simplestats_get_realtime', array( $this, 'get_realtime' ) );
		add_action( 'wp_ajax_simplestats_save_settings', array( $this, 'save_settings' ) );
		add_action( 'wp_ajax_simplestats_validate_connection', array( $this, 'validate_connection' ) );
		add_action( 'wp_ajax_simplestats_disconnect', array( $this, 'disconnect' ) );
		add_action( 'wp_ajax_simplestats_auth_create_user',          array( $this, 'auth_create_user' ) );
		add_action( 'wp_ajax_simplestats_auth_create_site',          array( $this, 'auth_create_site' ) );
		add_action( 'wp_ajax_simplestats_auth_sign_in',              array( $this, 'auth_sign_in' ) );
		add_action( 'wp_ajax_simplestats_auth_forgot',               array( $this, 'auth_forgot' ) );
	}

	/**
	 * Gate an admin AJAX action behind a capability.
	 *
	 * A nonce only proves the request was issued from a page we rendered — it is
	 * never proof that the user is *allowed* to act. Every `wp_ajax_*` action is
	 * reachable by any logged-in user (subscriber included), so each handler
	 * pairs its nonce check with this capability check. `manage_options` is the
	 * same capability that gates every SimpleStats admin screen, the admin-bar
	 * indicator and the dashboard widget, so no legitimate caller loses access.
	 *
	 * Sends a 403 and halts when the check fails.
	 *
	 * @param string $message Optional. Translated message returned to the caller.
	 */
	private function require_admin_capability( $message = '' ) {
		if ( current_user_can( 'manage_options' ) ) {
			return;
		}

		wp_send_json_error( array(
			'code'    => 'forbidden',
			'message' => '' !== $message ? $message : esc_html__( 'Permission denied.', 'simplestats-friendly-website-analytics' ),
		), 403 );
	}

	/**
	 * Activate a plugin.
	 */
	public function activate_plugin() {
		check_ajax_referer( 'updates' );

		if ( ! current_user_can( 'activate_plugins' ) ) {
			wp_send_json_error( array(
				'error' => esc_html__( 'You are not allowed to activate plugins', 'simplestats-friendly-website-analytics' ),
			) );
		}

		// Activate the addon.
		if ( empty( $_POST['plugin'] ) ) {
			wp_send_json_error( array(
				'error' => esc_html__( 'No plugin specified.', 'simplestats-friendly-website-analytics' ),
			) );
		}

		$plugin = sanitize_text_field( wp_unslash( $_POST['plugin'] ) );

		// Never let an arbitrary, request-supplied plugin path reach
		// activate_plugin(). Constrain it to the exact basenames SimpleStats
		// advertises as add-ons (the same list powering get_addons()), so this
		// endpoint can only ever toggle plugins we offer — not anything an
		// attacker manages to POST, even on a (capability-gated) admin request.
		$addons          = include __DIR__ . '/addons-list.php';
		$allowed_plugins = is_array( $addons ) ? wp_list_pluck( $addons, 'basename' ) : array();

		$allowed_index = array_search( $plugin, $allowed_plugins, true );

		if ( false === $allowed_index ) {
			wp_send_json_error( array(
				'error' => esc_html__( 'This plugin is not in the list of allowed SimpleStats add-ons.', 'simplestats-friendly-website-analytics' ),
			) );
		}

		// Activate using the value taken straight from our own allow-list (a
		// hardcoded plugin basename), never the request-supplied $plugin — so no
		// request data reaches activate_plugin(), even the equal-by-value string.
		$plugin    = $allowed_plugins[ $allowed_index ];
		$activated = activate_plugin( $plugin );

		if ( is_wp_error( $activated ) ) {
			wp_send_json_error( array(
				'error' => $activated->get_error_message(),
			) );
		}

		// Restrict third-party redirections on activation.
		if ( "userfeedback-lite/userfeedback.php" === $plugin ) {
			delete_transient( '_userfeedback_activation_redirect' );
		}

		wp_send_json_success();
	}

	/**
	 * Response notifications to users.
	 *
	 * @return void
	 */
	public function get_notifications() {
		check_ajax_referer( 'simplestats-action' );
		// Not a pure read: generating notifications writes the site-wide
		// `simplestats_notifications` option, so it needs the same gate as a save.
		$this->require_admin_capability();

		$notifications = SimpleStats_Notifications_Loader::get_instance();

		wp_send_json_success( $notifications->get_ajax_notifications() );
	}

	/**
	 * Dismiss a single notification.
	 *
	 * @return void
	 */
	public function dismiss_notification() {
		check_ajax_referer( 'simplestats-action' );
		// Writes the site-wide `simplestats_notifications` option.
		$this->require_admin_capability();

		$notifications = SimpleStats_Notifications_Loader::get_instance();

		if ( empty( $_POST['notification_id'] ) ) {
			wp_send_json_error();
		}

		$notifications->dismiss_by_id( sanitize_text_field( wp_unslash( $_POST['notification_id'] ) ) );

		wp_send_json_success();
	}

	/**
	 * Get our listed addons(plugin).
	 */
	public function get_addons() {
		check_ajax_referer( 'updates' );
		$this->require_admin_capability();

		$addons = include __DIR__ . '/addons-list.php';

		wp_send_json_success( $addons );
	}

	/**
	 * Range-aware dashboard report, cached as `simplestats_report_<from>_<to>`
	 * for 1 hour. Disconnect busts every transient with the `simplestats_report_` prefix.
	 */
	public function get_report() {
		check_ajax_referer( 'simplestats-action' );
		// Site analytics are only exposed to users who can see the dashboard.
		$this->require_admin_capability();

		$from = isset( $_POST['from'] ) ? sanitize_text_field( wp_unslash( $_POST['from'] ) ) : '';
		$to   = isset( $_POST['to'] )   ? sanitize_text_field( wp_unslash( $_POST['to'] ) )   : '';

		if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $from ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $to ) ) {
			wp_send_json_error( array( 'code' => 'bad_range' ), 400 );
		}

		// Caching (including the deliberate refusal to cache a zero-activity
		// report) lives in SimpleStats_Report so the dashboard widget cannot
		// write a different rule into the same transient.
		$data = SimpleStats_Report::get_instance()->get_cached_dashboard_report( $from, $to );

		if ( false === $data ) {
			wp_send_json_error( array( 'code' => 'upstream' ), 502 );
		}

		wp_send_json_success( $data );
	}

	/**
	 * Real-time visitors + recent activity. Underlying call is cached
	 * for 30s; see SimpleStats_Report::get_realtime().
	 */
	public function get_realtime() {
		check_ajax_referer( 'simplestats-action' );
		// Site analytics are only exposed to users who can see the dashboard.
		$this->require_admin_capability();

		$data = SimpleStats_Report::get_instance()->get_realtime();

		if ( false === $data ) {
			wp_send_json_error( array( 'code' => 'upstream' ), 502 );
		}

		wp_send_json_success( $data );
	}

	/**
	 * Persist plugin settings.
	 */
	public function save_settings() {
		check_ajax_referer( 'simplestats-action' );
		$this->require_admin_capability(
			esc_html__( 'You are not allowed to change these settings.', 'simplestats-friendly-website-analytics' )
		);

		$allowed = array( 'exclude_admins', 'show_admin_bar_indicator', 'show_dashboard_widget' );

		$incoming = isset( $_POST['settings'] ) && is_array( $_POST['settings'] )
			? map_deep( wp_unslash( $_POST['settings'] ), 'sanitize_text_field' )
			: array();

		$sanitized = array();
		foreach ( $allowed as $key ) {
			$sanitized[ $key ] = wp_validate_boolean( $incoming[ $key ] ?? false );
		}

		update_option( 'simplestats_settings', $sanitized, true );

		wp_send_json_success( $sanitized );
	}

	/**
	 * Validate the stored api_key against the SimpleStats app via the
	 * /wp-connector/action-validate route (no plan-permission gate).
	 * Result is cached in a 12-hour transient keyed on the api_key hash;
	 * the disconnect handler busts the transient so revocations are reflected
	 * immediately on this site.
	 */
	public function validate_connection() {
		check_ajax_referer( 'simplestats-action' );
		$this->require_admin_capability();

		$connection = simplestats_get_connection();

		if ( empty( $connection['api_key'] ) ) {
			wp_send_json_error( array(
				'code'    => 'not_connected',
				'message' => esc_html__( 'Site is not connected to SimpleStats.', 'simplestats-friendly-website-analytics' ),
			) );
		}

		$cache_key = 'simplestats_validation_' . md5( $connection['api_key'] );
		$cached    = get_transient( $cache_key );

		if ( is_array( $cached ) ) {
			wp_send_json_success( $cached );
		}

		$result = simplestats_auth_remote_post( 'wp-connector/action-validate', array(
			'api_key' => $connection['api_key'],
		) );

		if ( ! $result['ok'] ) {
			// The SaaS uses `not_connected` to signal that the stored api_key
			// is no longer valid (revoked, user deleted, etc.). When that
			// happens our local connection record is stale — tear it down so
			// the UI immediately reflects reality instead of trying to drive
			// off a dead key on the next refresh.
			if ( isset( $result['error']['code'] ) && 'not_connected' === $result['error']['code'] ) {
				$this->clear_connection( $connection );
			}
			wp_send_json_error( $result['error'] );
		}

		$data      = $result['data'];
		$plan      = isset( $data['plan'] ) ? sanitize_text_field( $data['plan'] ) : 'free';
		$plan_name = isset( $data['plan_name'] ) ? sanitize_text_field( $data['plan_name'] ) : '';
		$payload   = array(
			'email'        => isset( $data['email'] ) ? sanitize_text_field( $data['email'] ) : '',
			'plan'         => $plan,
			'plan_name'    => $plan_name,
			'connected_at' => isset( $data['connected_at'] ) ? (int) $data['connected_at'] : (int) ( $connection['connected_at'] ?? 0 ),
		);

		// Refresh the stored plan so PHP code (e.g. localized SimpleStatsVue.connection.plan)
		// reflects upgrades/downgrades without waiting for a reconnect.
		if ( ( $connection['plan'] ?? null ) !== $plan || ( $connection['plan_name'] ?? null ) !== $plan_name ) {
			update_option(
				'simplestats_connection',
				array_merge( $connection, array(
					'plan'      => $plan,
					'plan_name' => $plan_name,
				) ),
				false
			);
		}

		set_transient( $cache_key, $payload, 12 * HOUR_IN_SECONDS );

		wp_send_json_success( $payload );
	}

	/**
	 * Delete the local connection and invalidate the validation cache.
	 */
	public function disconnect() {
		check_ajax_referer( 'simplestats-action' );
		$this->require_admin_capability();

		$this->clear_connection( simplestats_get_connection() );

		wp_send_json_success();
	}

	/**
	 * Tear down all local artifacts of a SimpleStats connection:
	 *   - the validation transient keyed on the api_key
	 *   - the `simplestats_connection` option
	 *   - every admin's `simplestats_pending_signup` user meta (any half-finished signup)
	 *   - every cached dashboard-report transient (range keys are unbounded,
	 *     so a prefix DELETE on the options table is the cheapest correct path)
	 *
	 * Used both from the explicit disconnect AJAX and from validate_connection
	 * when the SaaS reports the api_key as no longer valid.
	 *
	 * @param array|false $connection Result of simplestats_get_connection(); may be empty.
	 */
	private function clear_connection( $connection ) {
		if ( ! empty( $connection['api_key'] ) ) {
			delete_transient( 'simplestats_validation_' . md5( $connection['api_key'] ) );
		}

		delete_option( 'simplestats_connection' );
		delete_metadata( 'user', 0, 'simplestats_pending_signup', '', true );

		global $wpdb;

		// esc_like() so the underscores in the prefix are matched literally
		// instead of acting as single-character LIKE wildcards.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options}
				 WHERE option_name LIKE %s
				    OR option_name LIKE %s",
				$wpdb->esc_like( '_transient_simplestats_report_' ) . '%',
				$wpdb->esc_like( '_transient_timeout_simplestats_report_' ) . '%'
			)
		);
	}

	/**
	 * Gate auth AJAX actions: capability + not-already-connected.
	 *
	 * Nonce verification is performed inline at the top of each auth handler
	 * (before any `$_POST` access) so PHPCS can prove the nonce was checked
	 * on the same code path that reads the request body.
	 */
	private function guard_auth_request() {
		$this->require_admin_capability();

		if ( simplestats_is_connected() ) {
			wp_send_json_error( array(
				'code'    => 'already_connected',
				'message' => esc_html__( 'Already connected.', 'simplestats-friendly-website-analytics' ),
			), 409 );
		}
	}

	/**
	 * Persist a successful connect response (sign-up or sign-in) to the WP option.
	 *
	 * @param array $data Payload from the app response (must contain pixel_key + api_key).
	 */
	private function persist_connection( $data ) {
		update_option( 'simplestats_connection', array(
			'email'        => isset( $data['email'] ) ? sanitize_email( $data['email'] ) : '',
			'pixel_key'    => isset( $data['pixel_key'] ) ? sanitize_text_field( $data['pixel_key'] ) : '',
			'api_key'      => isset( $data['api_key'] ) ? sanitize_text_field( $data['api_key'] ) : '',
			'plan'         => isset( $data['plan'] ) ? sanitize_text_field( $data['plan'] ) : 'free',
			'plan_name'    => isset( $data['plan_name'] ) ? sanitize_text_field( $data['plan_name'] ) : '',
			'connected_at' => time(),
		), false );
	}

	/**
	 * Stash the SaaS account (email + api_key + plan) created by action-new — or
	 * returned by action-login for a host with no site yet — pending website
	 * creation on the tracking-type screen. Kept in the current admin's user
	 * meta, never in `simplestats_connection`: the latter must only ever hold a
	 * complete, pixel_key-bearing connection so simplestats_is_connected()
	 * stays accurate. Per-user (not a site-wide option) so two admins running
	 * the signup flow at the same time can't overwrite each other's in-flight
	 * credentials and link the site to the wrong SaaS account.
	 *
	 * @param array  $data  Payload from the SaaS response.
	 * @param string $email Fallback email when the response omits it.
	 */
	private function persist_pending_signup( $data, $email = '' ) {
		update_user_meta( get_current_user_id(), 'simplestats_pending_signup', array(
			'email'     => isset( $data['email'] ) ? sanitize_email( $data['email'] ) : sanitize_email( $email ),
			'api_key'   => isset( $data['api_key'] ) ? sanitize_text_field( $data['api_key'] ) : '',
			'plan'      => isset( $data['plan'] ) ? sanitize_text_field( $data['plan'] ) : 'free',
			'plan_name' => isset( $data['plan_name'] ) ? sanitize_text_field( $data['plan_name'] ) : '',
		) );
	}

	/**
	 * AJAX: create the SaaS user account only (no website yet). The website is
	 * created later by auth_create_site() once a tracking mode is chosen, so an
	 * already-registered email is rejected here — on the sign-up screen, before
	 * the plugin advances to the tracking-type step.
	 */
	public function auth_create_user() {
		check_ajax_referer( 'simplestats-action' );
		$this->guard_auth_request();

		$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';

		// Newsletter opt-in checkbox. jQuery serializes 0 as the string "0", which
		// ! empty() already treats as false; the '0' guard documents that intent.
		$optin = ! empty( $_POST['optin'] ) && '0' !== sanitize_text_field( wp_unslash( $_POST['optin'] ) );

		if ( ! is_email( $email ) ) {
			wp_send_json_error( array(
				'code'    => 'email_invalid',
				'message' => esc_html__( 'Please enter a valid email address.', 'simplestats-friendly-website-analytics' ),
			) );
		}

		// If this same email was already created in a prior stage-1 submit (the
		// user hit "Back" on the tracking-type screen and resubmitted), don't
		// call the SaaS again: the account now exists, so action-new would
		// (correctly) report email_exists for our own in-progress signup.
		$pending = get_user_meta( get_current_user_id(), 'simplestats_pending_signup', true );
		if (
			is_array( $pending )
			&& ! empty( $pending['api_key'] )
			&& isset( $pending['email'] )
			&& strtolower( $pending['email'] ) === strtolower( $email )
		) {
			// The stash can outlive the SaaS account it points at (the account
			// was removed, or the app database was reset). Short-circuiting onto
			// a dead account would hand auth_create_site() a stale api_key that
			// the SaaS rejects with credentials_invalid — a dead-end the user
			// can't escape, since every resubmit short-circuits here again.
			// Confirm the stashed key still maps to a live account before reusing
			// it; if it doesn't, drop the stale stash and fall through to
			// recreate the account via action-new.
			$still_valid = simplestats_auth_remote_post( 'wp-connector/action-validate', array(
				'api_key' => $pending['api_key'],
			) );

			if ( ! empty( $still_valid['ok'] ) ) {
				wp_send_json_success( array( 'email' => $pending['email'] ) );
			}

			delete_user_meta( get_current_user_id(), 'simplestats_pending_signup' );
		}

		$result = simplestats_auth_remote_post( 'wp-connector/action-new', array(
			'email'    => $email,
			// Mirror the WordPress site timezone onto the new SaaS account so
			// dashboard date math matches the WP-admin view out of the box.
			'timezone' => wp_timezone_string(),
			// Newsletter opt-in: when true the SaaS adds the email to Lindris.
			'optin'    => $optin,
		) );

		if ( ! $result['ok'] ) {
			wp_send_json_error( $result['error'] );
		}

		$this->persist_pending_signup( $result['data'], $email );

		wp_send_json_success( array(
			'email' => isset( $result['data']['email'] ) ? $result['data']['email'] : $email,
		) );
	}

	/**
	 * AJAX: create the website for the pending account (a fresh sign-up, or a
	 * sign-in onto a host with no site yet) with the chosen tracking mode, then
	 * persist the completed connection. Authenticates to the SaaS with the
	 * (email, api_key) tuple stashed in `simplestats_pending_signup`.
	 */
	public function auth_create_site() {
		check_ajax_referer( 'simplestats-action' );
		// The already-connected half of the guard matters here: if another tab
		// (or another admin) completed a connection while this tab sat on the
		// tracking-type screen, replaying a stale pending stash through
		// action-create-site would overwrite the live connection with a
		// different SaaS account.
		$this->guard_auth_request();

		$pending = get_user_meta( get_current_user_id(), 'simplestats_pending_signup', true );
		if ( ! is_array( $pending ) || empty( $pending['email'] ) || empty( $pending['api_key'] ) ) {
			wp_send_json_error( array(
				'code'    => 'not_connected',
				'message' => esc_html__( 'Your session expired. Please start again.', 'simplestats-friendly-website-analytics' ),
			), 409 );
		}

		$result = simplestats_auth_remote_post( 'wp-connector/action-create-site', array(
			'email'         => $pending['email'],
			'api_key'       => $pending['api_key'],
			// SaaS whitelists this against {'lightweight','normal'}, so unknown
			// values land on 'normal' regardless of what we send.
			'tracking_type' => simplestats_sanitize_tracking_type( isset( $_POST['tracking_type'] ) ? sanitize_text_field( wp_unslash( $_POST['tracking_type'] ) ) : null ),
		) );

		if ( ! $result['ok'] ) {
			wp_send_json_error( $result['error'] );
		}

		$this->persist_connection( $result['data'] );
		// The site is linked now — every admin's half-finished signup is moot,
		// so clear all stashes, not just the current user's.
		delete_metadata( 'user', 0, 'simplestats_pending_signup', '', true );

		wp_send_json_success( array(
			'email' => isset( $result['data']['email'] ) ? $result['data']['email'] : $pending['email'],
		) );
	}

	/**
	 * AJAX: sign in an existing user on the SaaS app + persist the returned connection.
	 */
	public function auth_sign_in() {
		check_ajax_referer( 'simplestats-action' );
		$this->guard_auth_request();

		$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
		// Passwords are forwarded to the SaaS app verbatim and never stored locally;
		// running sanitize_text_field() here would strip valid characters.
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$password = isset( $_POST['password'] ) ? (string) wp_unslash( $_POST['password'] ) : '';

		if ( ! is_email( $email ) ) {
			wp_send_json_error( array(
				'code'    => 'email_invalid',
				'message' => esc_html__( 'Please enter a valid email address.', 'simplestats-friendly-website-analytics' ),
			) );
		}

		if ( '' === $password ) {
			wp_send_json_error( array(
				'code'    => 'password_required',
				'message' => esc_html__( 'Password is required.', 'simplestats-friendly-website-analytics' ),
			) );
		}

		$result = simplestats_auth_remote_post( 'wp-connector/action-login', array(
			'email'    => $email,
			'password' => $password,
		) );

		if ( ! $result['ok'] ) {
			wp_send_json_error( $result['error'] );
		}

		$data         = $result['data'];
		$site_was_new = ! empty( $data['site_was_new'] );

		if ( $site_was_new ) {
			// No website on this host yet — defer creation to the tracking-type
			// screen (auth_create_site), exactly like a fresh sign-up. action-login
			// returns no pixel_key in this case, so we can't complete the
			// connection until the user picks a mode.
			$this->persist_pending_signup( $data, $email );
		} else {
			// Host already linked: action-login returned its existing pixel_key,
			// so persist the complete connection and skip the tracking-type screen.
			$this->persist_connection( $data );
			// Drop any half-finished signup stash (e.g. the user started a
			// sign-up, backed out, and signed in instead) so a leftover tab
			// can't replay auth_create_site against the dead account.
			delete_metadata( 'user', 0, 'simplestats_pending_signup', '', true );
		}

		wp_send_json_success( array(
			'email' => isset( $data['email'] ) ? $data['email'] : $email,
			// JS uses this to decide whether to show the tracking-type picker
			// (true) or skip straight to /connecting (false, existing site).
			'site_was_new' => $site_was_new,
		) );
	}

	/**
	 * AJAX: proxy a forgot-password request to the SaaS app. Always reports success
	 * to avoid email enumeration.
	 */
	public function auth_forgot() {
		check_ajax_referer( 'simplestats-action' );
		$this->guard_auth_request();

		$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';

		if ( ! is_email( $email ) ) {
			wp_send_json_error( array(
				'code'    => 'email_invalid',
				'message' => esc_html__( 'Please enter a valid email address.', 'simplestats-friendly-website-analytics' ),
			) );
		}

		$result = simplestats_auth_remote_post( 'wp-connector/action-forgot', array(
			'email' => $email,
		) );

		if ( ! $result['ok'] ) {
			wp_send_json_error( $result['error'] );
		}

		wp_send_json_success();
	}
}
