<?php
defined( 'ABSPATH' ) || exit;

/**
 * REST endpoints for cross-domain handoff.
 *
 * These are the plugin's only public, unauthenticated routes, and they are
 * **registered only when the feature is enabled**. With handoff off they do not
 * exist at all — a request 404s because there is no route, not because a
 * permission callback refused it. That keeps the attack surface of a default
 * install exactly where it was.
 *
 * Routes:
 *   POST /bw-lead-ai/v1/handoff                  create or refresh (same-origin)
 *   GET  /bw-lead-ai/v1/handoff/<token>          claim, once     (cross-origin)
 *   POST /bw-lead-ai/v1/handoff/<token>/confirm  promote         (cross-origin)
 *
 * Security posture, stated plainly: **CORS is not authentication.** The allow-list
 * stops browser JavaScript on other origins; it does nothing against curl. The
 * actual control is token secrecy — 128 bits from a CSPRNG, hashed at rest, short
 * TTL, single-use for the data claim. CORS is defence in depth on top of that.
 *
 * That defence is only real because we remove WordPress core's CORS handler for
 * these routes — see suppress_core_cors(). Without that, core echoes any Origin
 * back and the allow-list here restricts nothing. If you refactor the hooks in
 * this class, re-run `tests/cors-check.sh`: this failure is invisible when you
 * only test from an allow-listed origin.
 */
class BW_Lead_AI_Handoff_REST {

	const NAMESPACE_V1 = 'bw-lead-ai/v1';

	/** Hard ceiling on a stored payload, after server-side filtering. */
	const MAX_PAYLOAD_BYTES = 16384;

	/** Per-datapoint ceilings. The journey summaries are the only large ones. */
	const MAX_FIELD_BYTES = array(
		'summary'          => 4096,
		'summary_detailed' => 8192,
		'events_list'      => 1024,
	);

	const MAX_SCALAR_BYTES = 300;

	/** Requests per hour, per hashed IP, per bucket. */
	const RATE_LIMITS = array(
		'create'  => 30,
		'claim'   => 120,
		'confirm' => 30,
	);

	/** Global ceiling on live pending records, so a distributed flood can't fill the table. */
	const MAX_PENDING = 10000;

	private static $instance = null;

	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	public function register() {
		if ( ! BW_Lead_AI_Settings::handoff_enabled() ) {
			return;
		}
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
		// Runs before rest_pre_serve_request, so core's CORS handler is gone by the
		// time headers are emitted. See suppress_core_cors().
		add_filter( 'rest_pre_dispatch', array( $this, 'suppress_core_cors' ), 10, 3 );
		// Priority 20: after anything else on this hook, so our header_remove()
		// backstop can strip permissive headers somebody else already set.
		add_filter( 'rest_pre_serve_request', array( $this, 'send_cors_headers' ), 20, 3 );
	}

	/**
	 * Is this request for one of our handoff routes?
	 */
	private function is_handoff_route( $request ) {
		if ( ! $request instanceof WP_REST_Request ) {
			return false;
		}
		return 0 === strpos( ltrim( $request->get_route(), '/' ), self::NAMESPACE_V1 . '/handoff' );
	}

	/**
	 * Remove WordPress core's CORS handler for our routes only.
	 *
	 * Core's `rest_send_cors_headers()` echoes **any** Origin back, together with
	 * `Access-Control-Allow-Credentials: true`. It is registered on the same
	 * `rest_pre_serve_request` hook we use, which means our allow-list only ever
	 * *added* headers for permitted origins — it never removed core's permissive
	 * ones for an origin we rejected. The allow-list was therefore decorative: a
	 * non-listed origin still received `Access-Control-Allow-Origin` from core,
	 * and the class docblock's claim of a restricted allow-list was not true of
	 * the shipped behaviour.
	 *
	 * Dropping core's handler here — scoped to our namespace, at dispatch time,
	 * before any header is emitted — makes send_cors_headers() the only source of
	 * CORS headers on these routes. Every other REST route keeps core's behaviour
	 * exactly as it was.
	 */
	public function suppress_core_cors( $result, $server, $request ) {
		if ( $this->is_handoff_route( $request ) ) {
			remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
		}
		return $result;
	}

	public function register_routes() {
		register_rest_route(
			self::NAMESPACE_V1,
			'/handoff',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'create' ),
				'permission_callback' => '__return_true',
			)
		);

		if ( BW_Lead_AI_Settings::handoff_mode_data() ) {
			register_rest_route(
				self::NAMESPACE_V1,
				'/handoff/(?P<token>[a-f0-9]{32})',
				array(
					'methods'             => 'GET',
					'callback'            => array( $this, 'claim' ),
					'permission_callback' => '__return_true',
				)
			);
		}

		if ( BW_Lead_AI_Settings::handoff_mode_link() ) {
			register_rest_route(
				self::NAMESPACE_V1,
				'/handoff/(?P<token>[a-f0-9]{32})/confirm',
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'confirm' ),
					'permission_callback' => '__return_true',
				)
			);
		}
	}

	// --- CORS -----------------------------------------------------------

	/**
	 * Echo CORS headers for allow-listed origins only, on our routes only.
	 *
	 * Never `*`, never with credentials, and `Vary: Origin` so a shared cache
	 * cannot serve one origin's allowance to another. Runs for preflight OPTIONS
	 * as well as the real request, because WordPress dispatches both through here.
	 */
	public function send_cors_headers( $served, $result, $request ) {
		if ( ! $this->is_handoff_route( $request ) ) {
			return $served;
		}

		header( 'Vary: Origin', false );
		header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );

		$origin  = BW_Lead_AI_Settings::normalize_origin( get_http_origin() );
		$allowed = ( '' !== $origin )
			&& in_array( $origin, BW_Lead_AI_Settings::parse_handoff_origins(), true );

		if ( ! $allowed ) {
			// A rejected origin must leave with NO CORS headers at all. These
			// removals are a backstop: suppress_core_cors() should already have
			// stopped core emitting them, but if any other plugin sets them we
			// still refuse rather than silently inherit somebody else's policy.
			header_remove( 'Access-Control-Allow-Origin' );
			header_remove( 'Access-Control-Allow-Methods' );
			header_remove( 'Access-Control-Allow-Credentials' );
			header_remove( 'Access-Control-Max-Age' );
			return $served;
		}

		header( 'Access-Control-Allow-Origin: ' . $origin );
		header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
		header( 'Access-Control-Allow-Headers: Content-Type' );
		header( 'Access-Control-Max-Age: 600' );

		// Never advertise credentials. Nothing in this design uses them — the
		// destination script fetches with credentials omitted — and allowing them
		// would widen what a page on an allow-listed origin could do with these
		// routes. Core sets this unconditionally, so remove it explicitly.
		header_remove( 'Access-Control-Allow-Credentials' );

		return $served;
	}

	// --- rate limiting ---------------------------------------------------

	/**
	 * The requesting IP, never stored raw.
	 *
	 * Defaults to REMOTE_ADDR rather than trusting a forwarded header, since those
	 * are trivially spoofed and would make the rate limit decorative. Sites behind
	 * a proxy that rewrites REMOTE_ADDR work as-is; anything more exotic can use
	 * the filter.
	 */
	private function client_ip() {
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : '';
		return (string) apply_filters( 'bw_lead_ai_client_ip', $ip );
	}

	/**
	 * A rate-limit key that identifies a client without recording who they are.
	 *
	 * The plugin does not log IP addresses — a deliberate, privacy-motivated choice
	 * inherited from its predecessor — so the counter is keyed by a salted daily
	 * hash. It is non-reversible, rotates itself every day, and nothing survives
	 * beyond the hourly counter transient.
	 */
	private function rate_key( $bucket ) {
		$hash = hash( 'sha256', wp_salt( 'auth' ) . '|' . gmdate( 'Y-m-d' ) . '|' . $this->client_ip() );
		return 'bw_lai_rl_' . $bucket . '_' . substr( $hash, 0, 32 );
	}

	private function rate_limited( $bucket ) {
		$limits = apply_filters( 'bw_lead_ai_handoff_rate_limits', self::RATE_LIMITS );
		$limit  = isset( $limits[ $bucket ] ) ? (int) $limits[ $bucket ] : 0;
		if ( $limit <= 0 ) {
			return false;
		}
		$key   = $this->rate_key( $bucket );
		$count = (int) get_transient( $key );
		if ( $count >= $limit ) {
			return true;
		}
		set_transient( $key, $count + 1, HOUR_IN_SECONDS );
		return false;
	}

	// --- helpers ---------------------------------------------------------

	/**
	 * Uniform "no such record" response.
	 *
	 * Missing, expired and already-claimed all return the same 404 so the endpoint
	 * cannot be used as an oracle to probe which tokens ever existed.
	 */
	private function not_found() {
		return new WP_REST_Response( array( 'error' => 'not_found' ), 404 );
	}

	/**
	 * Reduce a client-supplied payload to exactly the datapoints the site owner
	 * enabled, enforcing size caps.
	 *
	 * Filtering happens here, on the server, precisely because the front end is not
	 * trustworthy — a tampered script cannot widen what gets sent.
	 *
	 * @return array|WP_REST_Response Filtered payload, or an error response.
	 */
	private function filter_payload( $raw ) {
		if ( ! is_array( $raw ) ) {
			return new WP_REST_Response( array( 'error' => 'invalid_payload' ), 400 );
		}

		$allowed = BW_Lead_AI_Settings::parse_handoff_datapoints();
		$out     = array();

		foreach ( $allowed as $key ) {
			if ( ! isset( $raw[ $key ] ) || is_array( $raw[ $key ] ) ) {
				continue;
			}
			$value = (string) $raw[ $key ];
			if ( '' === $value ) {
				continue;
			}
			$cap = isset( self::MAX_FIELD_BYTES[ $key ] ) ? self::MAX_FIELD_BYTES[ $key ] : self::MAX_SCALAR_BYTES;
			if ( strlen( $value ) > $cap ) {
				$value = substr( $value, 0, $cap );
			}
			// Strip control characters but keep newlines and tabs, which the
			// summaries legitimately contain.
			$out[ $key ] = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value );
		}

		if ( empty( $out ) ) {
			return new WP_REST_Response( array( 'error' => 'empty_payload' ), 400 );
		}
		if ( strlen( (string) wp_json_encode( $out ) ) > self::MAX_PAYLOAD_BYTES ) {
			return new WP_REST_Response( array( 'error' => 'payload_too_large' ), 413 );
		}
		return $out;
	}

	// --- endpoints -------------------------------------------------------

	/**
	 * Create a record, or refresh an existing pending one.
	 *
	 * Deliberately no REST nonce. This runs on public, cacheable pages; a page
	 * cache or CDN would serve a stale nonce and the feature would fail silently
	 * for a subset of visitors — exactly the failure mode this design is trying to
	 * avoid. It is treated as a public endpoint and defended with rate limits,
	 * payload caps, server-side filtering and a global pending ceiling instead.
	 */
	public function create( WP_REST_Request $request ) {
		if ( $this->rate_limited( 'create' ) ) {
			return new WP_REST_Response( array( 'error' => 'rate_limited' ), 429 );
		}

		$payload = $this->filter_payload( $request->get_param( 'data' ) );
		if ( $payload instanceof WP_REST_Response ) {
			return $payload;
		}

		$settings = BW_Lead_AI_Settings::get();
		$existing = (string) $request->get_param( 'token' );

		// Refresh path: requires possession of a live pending token. Never extends
		// expiry, so a refresh loop cannot keep a record alive indefinitely.
		if ( '' !== $existing && BW_Lead_AI_Handoff_Store::is_token_shaped( $existing ) ) {
			if ( BW_Lead_AI_Handoff_Store::refresh( $existing, $payload ) ) {
				return new WP_REST_Response( array( 'token' => $existing, 'refreshed' => true ), 200 );
			}
			// Fall through and mint a new one — the old token had expired.
		}

		if ( BW_Lead_AI_Handoff_Store::pending_count() >= self::MAX_PENDING ) {
			return new WP_REST_Response( array( 'error' => 'capacity' ), 503 );
		}

		$ttl   = (int) $settings['handoff_ttl'];
		$token = BW_Lead_AI_Handoff_Store::mint( $payload, $ttl );
		if ( is_wp_error( $token ) ) {
			return new WP_REST_Response( array( 'error' => 'store_unavailable' ), 503 );
		}

		return new WP_REST_Response(
			array(
				'token'      => $token,
				'expires_in' => $ttl * MINUTE_IN_SECONDS,
			),
			201
		);
	}

	/**
	 * Claim the payload. Single-use — see BW_Lead_AI_Handoff_Store::claim().
	 */
	public function claim( WP_REST_Request $request ) {
		if ( $this->rate_limited( 'claim' ) ) {
			return new WP_REST_Response( array( 'error' => 'rate_limited' ), 429 );
		}
		$payload = BW_Lead_AI_Handoff_Store::claim( (string) $request->get_param( 'token' ) );
		if ( null === $payload ) {
			return $this->not_found();
		}
		return new WP_REST_Response( array( 'data' => $payload ), 200 );
	}

	/**
	 * Promote a record to confirmed. Fired by the destination on its form-submit
	 * trigger, which is what turns a throwaway pending record into a kept one.
	 */
	public function confirm( WP_REST_Request $request ) {
		if ( $this->rate_limited( 'confirm' ) ) {
			return new WP_REST_Response( array( 'error' => 'rate_limited' ), 429 );
		}

		$context = sanitize_text_field( (string) $request->get_param( 'context' ) );
		if ( strlen( $context ) > 200 ) {
			$context = substr( $context, 0, 200 );
		}

		$ok = BW_Lead_AI_Handoff_Store::confirm(
			(string) $request->get_param( 'token' ),
			$context,
			BW_Lead_AI_Settings::handoff_retention_days()
		);
		if ( ! $ok ) {
			return $this->not_found();
		}
		return new WP_REST_Response( array( 'confirmed' => true ), 200 );
	}
}
