<?php
/**
 * AJAX handlers for AccessLens.
 *
 * Actions registered (admin only, requires manage_options):
 *   accesslens_start_scan  — start a new scan job
 *   accesslens_poll_scan   — poll job status
 *   accesslens_get_report  — proxy report download
 *
 * @package AccessLens
 */

declare(strict_types=1);

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

/**
 * AccessLens_Ajax
 */
class AccessLens_Ajax {

	/** @var AccessLens_Engine_Client */
	private AccessLens_Engine_Client $client;

	/** Transient TTL: 4 hours. */
	private const JOB_TTL = 4 * HOUR_IN_SECONDS;

	/** Max entries kept in the `accesslens_scan_history` option. */
	private const HISTORY_CAP = 50;

	/**
	 * @param AccessLens_Engine_Client $client Engine HTTP client.
	 */
	public function __construct( AccessLens_Engine_Client $client ) {
		$this->client = $client;
	}

	/**
	 * Register all AJAX actions.
	 */
	public function register(): void {
		add_action( 'wp_ajax_accesslens_start_scan', array( $this, 'handle_start_scan' ) );
		add_action( 'wp_ajax_accesslens_poll_scan', array( $this, 'handle_poll_scan' ) );
		add_action( 'wp_ajax_accesslens_get_report', array( $this, 'handle_get_report' ) );
		add_action( 'wp_ajax_accesslens_dismiss_onboarding', array( $this, 'handle_dismiss_onboarding' ) );
		add_action( 'wp_ajax_accesslens_dismiss_review', array( $this, 'handle_dismiss_review' ) );
	}

	// ----------------------------------------------------------------
	// Helpers
	// ----------------------------------------------------------------

	/**
	 * Verify nonce and capability, die on failure.
	 */
	private function verify_request(): void {
		check_ajax_referer( 'accesslens_audit_nonce', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'accesslens' ) ), 403 );
		}
	}

	/**
	 * Get transient key for current user's active job.
	 */
	private function job_transient_key(): string {
		return 'accesslens_job_' . get_current_user_id();
	}

	/**
	 * Persist the active job for the current user. Stores the scanned URL and
	 * mode alongside the jobId so we can label the persisted "last scan"
	 * (option accesslens_last_scan) and append to the scan history once the
	 * job completes.
	 *
	 * @param string $job_id   Job identifier returned by the engine.
	 * @param string $scan_url URL originally submitted for this job.
	 * @param string $mode     Scan mode originally submitted for this job.
	 */
	private function set_active_job( string $job_id, string $scan_url, string $mode ): void {
		set_transient(
			$this->job_transient_key(),
			array(
				'jobId' => $job_id,
				'url'   => $scan_url,
				'mode'  => $mode,
			),
			self::JOB_TTL
		);
	}

	/**
	 * Get the active job for the current user.
	 *
	 * @return array{jobId:string,url:string,mode:string}|null
	 */
	private function get_active_job(): ?array {
		$job = get_transient( $this->job_transient_key() );
		if ( empty( $job ) ) {
			return null;
		}

		// Retrocompatibilidad: versiones anteriores guardaban solo el jobId (string).
		if ( is_string( $job ) ) {
			return array(
				'jobId' => $job,
				'url'   => '',
				'mode'  => 'single',
			);
		}

		if ( ! is_array( $job ) || empty( $job['jobId'] ) ) {
			return null;
		}

		return array(
			'jobId' => (string) $job['jobId'],
			'url'   => (string) ( $job['url'] ?? '' ),
			'mode'  => (string) ( $job['mode'] ?? 'single' ),
		);
	}

	/**
	 * Defensa en profundidad: el motor tambien valida esto, pero lo comprobamos
	 * en el plugin antes de llamar al motor para no gastar cuota en peticiones invalidas.
	 *
	 * @param string $url URL a comprobar.
	 * @return bool True si el host de $url coincide con el host del propio sitio.
	 */
	private function is_same_site_host( string $url ): bool {
		$target_host = wp_parse_url( $url, PHP_URL_HOST );
		$site_host   = wp_parse_url( home_url(), PHP_URL_HOST );

		if ( empty( $target_host ) || empty( $site_host ) ) {
			return false;
		}

		return 0 === strcasecmp( (string) $target_host, (string) $site_host );
	}

	/**
	 * Map a WP_Error coming from AccessLens_Engine_Client to an HTTP status + a
	 * clear Spanish message for the browser. Centraliza el mapeo de codigos
	 * estables del motor (pro_required, quota_exceeded, rate_limited,
	 * domain_not_allowed, service_busy) para los 3 handlers AJAX.
	 *
	 * @param WP_Error $error Error devuelto por el cliente del motor.
	 * @return array{status:int,payload:array}
	 */
	private function engine_error_to_response( WP_Error $error ): array {
		$code        = $error->get_error_code();
		$data        = $error->get_error_data();
		$data        = is_array( $data ) ? $data : array();
		$reset_at    = sanitize_text_field( (string) ( $data['resetAt'] ?? '' ) );
		$retry_after = absint( $data['retryAfter'] ?? 0 );
		$http_status = ! empty( $data['http_status'] ) ? (int) $data['http_status'] : 502;

		switch ( $code ) {
			case 'pro_required':
				$upgrade_url = function_exists( 'accesslens_fs' ) ? accesslens_fs()->get_upgrade_url() : '';
				return array(
					'status'  => 402,
					'payload' => array(
						'message'    => __( 'This feature requires a Pro subscription to the AccessLens service.', 'accesslens' ),
						'upgradeUrl' => esc_url_raw( (string) $upgrade_url ),
					),
				);

			case 'quota_exceeded':
				return array(
					'status'  => 429,
					'payload' => array(
						'message' => __( 'You have reached the daily scan limit. It resets tomorrow.', 'accesslens' ),
						'resetAt' => $reset_at,
					),
				);

			case 'rate_limited':
				return array(
					'status'  => 429,
					'payload' => array(
						'message'    => __( 'Too many requests in a row. Wait a few seconds and try again.', 'accesslens' ),
						'retryAfter' => $retry_after,
					),
				);

			case 'domain_not_allowed':
				return array(
					'status'  => 403,
					'payload' => array(
						'message' => __( 'You can only audit pages on your own site.', 'accesslens' ),
					),
				);

			case 'service_busy':
				return array(
					'status'  => 503,
					'payload' => array(
						'message'    => __( 'The service is currently busy. Try again in a few minutes.', 'accesslens' ),
						'retryAfter' => $retry_after,
					),
				);

			default:
				return array(
					'status'  => $http_status,
					'payload' => array( 'message' => $error->get_error_message() ),
				);
		}
	}

	/**
	 * Sanitize the quota object the engine may include in its responses.
	 *
	 * @param array $data Raw data from engine (start_scan or get_status response).
	 * @return array|null Sanitized quota, or null if the engine did not send one.
	 */
	private function sanitize_quota( array $data ): ?array {
		if ( ! isset( $data['quota'] ) || ! is_array( $data['quota'] ) ) {
			return null;
		}

		$quota = $data['quota'];

		return array(
			'plan'       => sanitize_text_field( (string) ( $quota['plan'] ?? '' ) ),
			'scansUsed'  => absint( $quota['scansUsed'] ?? 0 ),
			'scansLimit' => absint( $quota['scansLimit'] ?? 0 ),
			'resetAt'    => sanitize_text_field( (string) ( $quota['resetAt'] ?? '' ) ),
		);
	}

	// ----------------------------------------------------------------
	// Handlers
	// ----------------------------------------------------------------

	/**
	 * Handle accesslens_start_scan AJAX action.
	 */
	public function handle_start_scan(): void {
		$this->verify_request(); // verifica nonce (check_ajax_referer) + capability.

		// El nonce se verifica en verify_request(); PHPCS no rastrea helpers.
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		// Sanitize inputs.
		$raw_url  = isset( $_POST['scan_url'] ) ? sanitize_text_field( wp_unslash( $_POST['scan_url'] ) ) : '';
		$raw_mode = isset( $_POST['scan_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['scan_mode'] ) ) : 'single';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$scan_url = esc_url_raw( $raw_url );
		if ( empty( $scan_url ) ) {
			wp_send_json_error( array( 'message' => __( 'Invalid URL.', 'accesslens' ) ), 400 );
			return;
		}

		// Defensa en profundidad: el motor tambien restringe el target al mismo dominio,
		// pero lo comprobamos aqui primero para no gastar cuota en peticiones invalidas.
		if ( ! $this->is_same_site_host( $scan_url ) ) {
			$mapped = $this->engine_error_to_response( new WP_Error( 'domain_not_allowed', '' ) );
			wp_send_json_error( $mapped['payload'], $mapped['status'] );
			return;
		}

		// Validacion basica del modo. El nivel free/Pro lo decide el servicio externo
		// (serviceware): el plugin no bloquea funciones; envia la licencia y el motor aplica el plan.
		$mode = in_array( $raw_mode, array( 'single', 'sitemap', 'crawl' ), true ) ? $raw_mode : 'single';

		$result = $this->client->start_scan( $scan_url, $mode, accesslens_license_key() );

		if ( is_wp_error( $result ) ) {
			$mapped = $this->engine_error_to_response( $result );
			wp_send_json_error( $mapped['payload'], $mapped['status'] );
			return;
		}

		// Persist jobId + scanned URL + mode for this user (se usan para etiquetar
		// el "ultimo scan" persistido y para el historico de scans cuando el job termine).
		$this->set_active_job( sanitize_text_field( $result['jobId'] ), $scan_url, $mode );

		wp_send_json_success(
			array(
				'jobId'     => esc_html( $result['jobId'] ),
				'status'    => esc_html( $result['status'] ?? 'queued' ),
				'pageLimit' => absint( $result['pageLimit'] ?? 0 ),
				'quota'     => $this->sanitize_quota( $result ),
			)
		);
	}

	/**
	 * Handle accesslens_poll_scan AJAX action.
	 */
	public function handle_poll_scan(): void {
		$this->verify_request();

		$job = $this->get_active_job();
		if ( null === $job ) {
			wp_send_json_error( array( 'message' => __( 'There is no active scan for this user.', 'accesslens' ) ), 404 );
			return;
		}

		$result = $this->client->get_status( $job['jobId'] );

		if ( is_wp_error( $result ) ) {
			$mapped = $this->engine_error_to_response( $result );
			wp_send_json_error( $mapped['payload'], $mapped['status'] );
			return;
		}

		$sanitized = $this->sanitize_status_response( $result );

		// Job terminado: liberamos el transient de inmediato en vez de esperar el TTL de 4h.
		if ( in_array( $sanitized['status'], array( 'done', 'error' ), true ) ) {
			delete_transient( $this->job_transient_key() );
		}

		if ( 'done' === $sanitized['status'] ) {
			$sanitized['history'] = $this->persist_last_scan( $job['url'], $job['mode'], $sanitized );
			AccessLens_Options::increment_scan_count();
		}

		wp_send_json_success( $sanitized );
	}

	/**
	 * Persist the completed scan as option `accesslens_last_scan` (autoload no)
	 * and append a compact entry to the `accesslens_scan_history` option.
	 * Flattens violations across all scanned pages and caps the stored list at
	 * 100 entries (payloads for sitemap/crawl scans can be large).
	 *
	 * @param string $scan_url  URL originally submitted for this job.
	 * @param string $mode      Scan mode originally submitted for this job.
	 * @param array  $sanitized Sanitized status response (see sanitize_status_response()).
	 * @return array Updated scan history (see append_scan_history()), for the AJAX response.
	 */
	private function persist_last_scan( string $scan_url, string $mode, array $sanitized ): array {
		$stats = array(
			'critical' => 0,
			'serious'  => 0,
			'moderate' => 0,
			'minor'    => 0,
			'total'    => 0,
		);

		$violations = array();

		foreach ( $sanitized['results'] as $page ) {
			foreach ( array_keys( $stats ) as $severity ) {
				$stats[ $severity ] += absint( $page['summary'][ $severity ] ?? 0 );
			}

			foreach ( $page['violations'] as $violation ) {
				$violations[] = array_merge( $violation, array( 'pageUrl' => $page['url'] ) );
			}
		}

		$truncated = count( $violations ) > 100;

		AccessLens_Options::set(
			'accesslens_last_scan',
			array(
				'timestamp'  => time(),
				'url'        => esc_url_raw( $scan_url ),
				'stats'      => $stats,
				'violations' => array_slice( $violations, 0, 100 ),
				'truncated'  => $truncated,
			)
		);

		return $this->append_scan_history( $scan_url, $mode, count( $sanitized['results'] ), $stats );
	}

	/**
	 * Append a compact entry (no violation detail — that lives in
	 * accesslens_last_scan) to the scan history option, capped at
	 * self::HISTORY_CAP entries (oldest discarded first).
	 *
	 * @param string $scan_url      URL originally submitted for this job.
	 * @param string $mode          Scan mode ('single'|'sitemap'|'crawl').
	 * @param int    $pages_scanned Number of pages included in this scan.
	 * @param array  $stats         Severity counts {critical,serious,moderate,minor[,total]}.
	 * @return array Updated, capped history (oldest first).
	 */
	private function append_scan_history( string $scan_url, string $mode, int $pages_scanned, array $stats ): array {
		$history = AccessLens_Options::get( 'accesslens_scan_history', array() );
		if ( ! is_array( $history ) ) {
			$history = array();
		}

		$history[] = array(
			'timestamp'    => time(),
			'url'          => esc_url_raw( $scan_url ),
			'mode'         => in_array( $mode, array( 'single', 'sitemap', 'crawl' ), true ) ? $mode : 'single',
			'pagesScanned' => absint( $pages_scanned ),
			'stats'        => array(
				'critical' => absint( $stats['critical'] ?? 0 ),
				'serious'  => absint( $stats['serious'] ?? 0 ),
				'moderate' => absint( $stats['moderate'] ?? 0 ),
				'minor'    => absint( $stats['minor'] ?? 0 ),
			),
		);

		if ( count( $history ) > self::HISTORY_CAP ) {
			$history = array_slice( $history, -self::HISTORY_CAP );
		}

		AccessLens_Options::set( 'accesslens_scan_history', $history );

		return $history;
	}

	/**
	 * Handle accesslens_get_report AJAX action.
	 * Proxies the PDF/HTML from the engine and forces browser download.
	 */
	public function handle_get_report(): void {
		$this->verify_request();

		$job = $this->get_active_job();
		if ( null === $job ) {
			wp_send_json_error( array( 'message' => __( 'There is no completed scan to download.', 'accesslens' ) ), 404 );
			return;
		}
		$job_id = $job['jobId'];

		// El nonce se verifica en verify_request(); PHPCS no rastrea helpers.
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		// Collect and sanitize report metadata from POST.
		// Nota: 'siteUrl' no se recoge aqui — AccessLens_Engine_Client::get_report() lo fija
		// siempre a home_url('/'), el motor lo usa para restringir el target al propio dominio.
		$meta = array(
			'siteName'    => sanitize_text_field( wp_unslash( $_POST['site_name'] ?? get_bloginfo( 'name' ) ) ),
			'entityType'  => in_array( wp_unslash( $_POST['entity_type'] ?? '' ), array( 'private', 'public' ), true )
				? sanitize_text_field( wp_unslash( $_POST['entity_type'] ) )
				: 'private',
			'contactEmail' => sanitize_email( wp_unslash( $_POST['contact_email'] ?? '' ) ),
			'contactUrl'   => esc_url_raw( wp_unslash( $_POST['contact_url'] ?? '' ) ),
			'preparedBy'   => sanitize_text_field( wp_unslash( $_POST['prepared_by'] ?? '' ) ),
		);
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$format = 'pdf'; // Force PDF for now.

		// Envia la licencia; el motor decide si incluye las funciones Pro (IA) en el informe.
		$result = $this->client->get_report( $job_id, $meta, $format, accesslens_license_key() );

		if ( is_wp_error( $result ) ) {
			$mapped = $this->engine_error_to_response( $result );
			wp_send_json_error( $mapped['payload'], $mapped['status'] );
			return;
		}

		// Stream binary to browser.
		$content_type = $result['content_type'] ?: 'application/pdf';
		$filename      = 'accesslens-report-' . sanitize_title( $meta['siteName'] ) . '.pdf';

		header( 'Content-Type: ' . sanitize_mime_type( $content_type ) );
		header( 'Content-Disposition: attachment; filename="' . rawurlencode( $filename ) . '"' );
		header( 'Content-Length: ' . strlen( $result['body'] ) );
		header( 'Cache-Control: no-store' );

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo $result['body'];
		wp_die();
	}

	// ----------------------------------------------------------------
	// Sanitizers
	// ----------------------------------------------------------------

	/**
	 * Sanitize the status response array before sending to JS.
	 *
	 * @param array $data Raw data from engine.
	 * @return array Sanitized data.
	 */
	private function sanitize_status_response( array $data ): array {
		$allowed_statuses = array( 'queued', 'discovering', 'running', 'done', 'error' );
		$status           = sanitize_text_field( $data['status'] ?? 'queued' );
		if ( ! in_array( $status, $allowed_statuses, true ) ) {
			$status = 'queued';
		}

		$progress = array(
			'total' => absint( $data['progress']['total'] ?? 0 ),
			'done'  => absint( $data['progress']['done'] ?? 0 ),
		);

		$results = array();
		if ( is_array( $data['results'] ?? null ) ) {
			foreach ( $data['results'] as $page ) {
				if ( ! is_array( $page ) ) {
					continue;
				}
				$results[] = $this->sanitize_page_result( $page );
			}
		}

		$errors = array();
		if ( is_array( $data['errors'] ?? null ) ) {
			foreach ( $data['errors'] as $err ) {
				$errors[] = sanitize_text_field( (string) $err );
			}
		}

		return array(
			'status'   => $status,
			'progress' => $progress,
			'results'  => $results,
			'errors'   => $errors,
			'error'    => sanitize_text_field( $data['error'] ?? '' ),
			'quota'    => $this->sanitize_quota( $data ),
		);
	}

	/**
	 * Sanitize a single page result object.
	 *
	 * @param array $page Raw page result.
	 * @return array Sanitized.
	 */
	private function sanitize_page_result( array $page ): array {
		$summary = array(
			'total'    => absint( $page['summary']['total'] ?? 0 ),
			'critical' => absint( $page['summary']['critical'] ?? 0 ),
			'serious'  => absint( $page['summary']['serious'] ?? 0 ),
			'moderate' => absint( $page['summary']['moderate'] ?? 0 ),
			'minor'    => absint( $page['summary']['minor'] ?? 0 ),
		);

		$violations = array();
		if ( is_array( $page['violations'] ?? null ) ) {
			foreach ( $page['violations'] as $v ) {
				if ( ! is_array( $v ) ) {
					continue;
				}
				$wcag = array();
				if ( is_array( $v['wcagCriteria'] ?? null ) ) {
					foreach ( $v['wcagCriteria'] as $w ) {
						$wcag[] = sanitize_text_field( (string) $w );
					}
				}
				$violations[] = array(
					'id'           => sanitize_text_field( $v['id'] ?? '' ),
					'impact'       => sanitize_text_field( $v['impact'] ?? '' ),
					'wcagCriteria' => $wcag,
					'help'         => sanitize_text_field( $v['help'] ?? '' ),
					'nodes'        => absint( count( $v['nodes'] ?? array() ) ),
				);
			}
		}

		return array(
			'url'        => esc_url_raw( $page['url'] ?? '' ),
			'pageTitle'  => sanitize_text_field( $page['pageTitle'] ?? '' ),
			'summary'    => $summary,
			'violations' => $violations,
		);
	}

	// ----------------------------------------------------------------
	// Dismissible notices
	// ----------------------------------------------------------------

	/**
	 * Handle accesslens_dismiss_onboarding AJAX action.
	 */
	public function handle_dismiss_onboarding(): void {
		$this->verify_request();
		AccessLens_Options::set( 'accesslens_onboarding_dismissed', true );
		wp_send_json_success();
	}

	/**
	 * Handle accesslens_dismiss_review AJAX action.
	 */
	public function handle_dismiss_review(): void {
		$this->verify_request();
		AccessLens_Options::set( 'accesslens_review_dismissed', true );
		wp_send_json_success();
	}
}
