<?php
/**
 * REST controller: /wp-json/bw-guides/v1/ — the hub contract for client sites.
 *
 * Auth: every data route requires a per-site key in the X-BW-Guides-Key header,
 * verified in the permission_callback. Only /health is public.
 */

defined( 'ABSPATH' ) || exit;

class BW_Guides_Server_REST {

	const REST_NAMESPACE = 'bw-guides/v1';
	const KEY_HEADER     = 'X-BW-Guides-Key';
	const MAX_INCLUDE    = 20;
	const MAX_GUIDES     = 500;

	/**
	 * Site row authenticated by the permission_callback, reused by the route
	 * callback within the same request.
	 *
	 * @var object|null
	 */
	private $authed_site = null;

	public function register() {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	public function register_routes() {
		register_rest_route(
			self::REST_NAMESPACE,
			'/health',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'handle_health' ),
				'permission_callback' => '__return_true',
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/manifest',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'handle_manifest' ),
				'permission_callback' => array( $this, 'check_site_key' ),
				'args'                => array(
					'client' => array(
						'type'              => 'string',
						'required'          => false,
						'sanitize_callback' => 'sanitize_text_field',
					),
					'schema' => array(
						'type'     => 'integer',
						'required' => false,
					),
				),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/guides',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'handle_guides' ),
				'permission_callback' => array( $this, 'check_site_key' ),
				'args'                => array(
					'include' => array(
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
					'client'  => array(
						'type'              => 'string',
						'required'          => false,
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);
	}

	public function check_site_key( WP_REST_Request $request ) {
		$key  = (string) $request->get_header( self::KEY_HEADER );
		$site = BW_Guides_Server_Sites::verify_key( $key );
		if ( is_wp_error( $site ) ) {
			$this->authed_site = null;
			return $site;
		}
		$this->authed_site = $site;
		return true;
	}

	public function handle_health() {
		return rest_ensure_response(
			array(
				'status'  => 'ok',
				'version' => BW_GUIDES_SERVER_VERSION,
				'time'    => current_time( 'mysql', 1 ),
			)
		);
	}

	public function handle_manifest( WP_REST_Request $request ) {
		$site = $this->authed_site;
		if ( ! $site ) {
			return new WP_Error( 'bw_guides_invalid_key', __( 'Invalid or missing site key.', 'bw-guides-server' ), array( 'status' => 401 ) );
		}

		BW_Guides_Server_Sites::touch( (int) $site->id, $this->get_client_ip(), (string) $request->get_param( 'client' ) );

		$guides = array();
		foreach ( $this->visible_guides( $site ) as $post ) {
			$guides[] = array(
				'id'           => (int) $post->ID,
				'slug'         => $post->post_name,
				'title'        => $post->post_title,
				'modified_gmt' => $post->post_modified_gmt,
				'content_hash' => 'sha256:' . hash( 'sha256', (string) $post->post_content ),
				'audience'     => $this->guide_audience( $post->ID ),
			);
		}

		return rest_ensure_response(
			array(
				'schema_version' => BW_GUIDES_SERVER_SCHEMA_VERSION,
				'hub_version'    => BW_GUIDES_SERVER_VERSION,
				'generated_at'   => current_time( 'mysql', 1 ),
				'site'           => array(
					'id'   => (int) $site->id,
					'name' => $site->site_name,
				),
				'guides'         => $guides,
			)
		);
	}

	public function handle_guides( WP_REST_Request $request ) {
		$site = $this->authed_site;
		if ( ! $site ) {
			return new WP_Error( 'bw_guides_invalid_key', __( 'Invalid or missing site key.', 'bw-guides-server' ), array( 'status' => 401 ) );
		}

		$ids = array_filter( array_map( 'absint', explode( ',', (string) $request->get_param( 'include' ) ) ) );
		$ids = array_slice( array_unique( $ids ), 0, self::MAX_INCLUDE );
		if ( empty( $ids ) ) {
			return new WP_Error( 'bw_guides_bad_request', __( 'include must be a comma-separated list of guide ids.', 'bw-guides-server' ), array( 'status' => 400 ) );
		}

		BW_Guides_Server_Sites::touch( (int) $site->id, $this->get_client_ip(), (string) $request->get_param( 'client' ) );

		// Ids not visible to the requesting site are silently omitted (no 403 oracle).
		$guides = array();
		foreach ( $this->visible_guides( $site, $ids ) as $post ) {
			$guides[] = array(
				'id'           => (int) $post->ID,
				'slug'         => $post->post_name,
				'title'        => $post->post_title,
				'excerpt'      => (string) $post->post_excerpt,
				'content'      => (string) $post->post_content,
				'modified_gmt' => $post->post_modified_gmt,
				'content_hash' => 'sha256:' . hash( 'sha256', (string) $post->post_content ),
				'meta'         => array(
					'min_wp_version'   => null,
					'requires_plugins' => array(),
					'tokens'           => array(),
					// Added 0.2.0 — forward-compat carrier, additive only (see
					// BW_Guides_Server_Admin's keyword metabox for how these are
					// authored). Never rewritten at sync time on the client; it
					// reads this meta raw at render/query time.
					'category'         => (string) get_post_meta( $post->ID, '_bw_guides_category', true ),
					'section_keywords' => $this->section_keywords( $post->ID ),
				),
			);
		}

		return rest_ensure_response(
			array(
				'schema_version' => BW_GUIDES_SERVER_SCHEMA_VERSION,
				'guides'         => $guides,
			)
		);
	}

	/**
	 * All published guides visible to a site: audience 'all'; or 'selected'
	 * with the site in the target list; or 'client' with the site's
	 * client_slug matching the guide's target client.
	 *
	 * @param object $site        Authenticated site row (needs ->id, ->client_slug).
	 * @param array  $include_ids Optional post__in restriction.
	 * @return WP_Post[]
	 */
	private function visible_guides( $site, $include_ids = null ) {
		$args = array(
			'post_type'              => BW_Guides_Server_CPT::POST_TYPE,
			'post_status'            => 'publish',
			'posts_per_page'         => self::MAX_GUIDES,
			'orderby'                => 'ID',
			'order'                  => 'ASC',
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
		);
		if ( ! empty( $include_ids ) ) {
			$args['post__in'] = $include_ids;
		}

		$query   = new WP_Query( $args );
		$visible = array();
		foreach ( $query->posts as $post ) {
			$audience = $this->guide_audience( $post->ID );
			if ( 'selected' === $audience ) {
				$targets = array_map( 'intval', (array) get_post_meta( $post->ID, '_bw_guides_target_sites', true ) );
				if ( ! in_array( (int) $site->id, $targets, true ) ) {
					continue;
				}
			} elseif ( 'client' === $audience ) {
				$target_client = (string) get_post_meta( $post->ID, '_bw_guides_target_client', true );
				$site_client   = (string) $site->client_slug;
				if ( '' === $site_client || '' === $target_client || $site_client !== $target_client ) {
					continue;
				}
			}
			$visible[] = $post;
		}
		return $visible;
	}

	private function guide_audience( $post_id ) {
		$audience = get_post_meta( $post_id, '_bw_guides_audience', true );
		if ( 'selected' === $audience || 'client' === $audience ) {
			return $audience;
		}
		return 'all';
	}

	/**
	 * Read + validate the authored section-keyword rules for a guide.
	 * Malformed/legacy data degrades to [] rather than erroring the payload.
	 *
	 * @return array<int, array{heading:string, keywords:string}>
	 */
	private function section_keywords( $post_id ) {
		$raw = get_post_meta( $post_id, '_bw_guides_section_keywords', true );
		if ( ! is_array( $raw ) ) {
			return array();
		}
		$out = array();
		foreach ( $raw as $row ) {
			if ( ! is_array( $row ) || ! isset( $row['heading'], $row['keywords'] ) ) {
				continue;
			}
			$out[] = array(
				'heading'  => (string) $row['heading'],
				'keywords' => (string) $row['keywords'],
			);
		}
		return $out;
	}

	private function get_client_ip() {
		// Trust CF-Connecting-IP if the request came through Cloudflare (Caddy sets this).
		$headers = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR' );
		foreach ( $headers as $h ) {
			if ( ! empty( $_SERVER[ $h ] ) ) {
				$ip = sanitize_text_field( wp_unslash( $_SERVER[ $h ] ) );
				$ip = trim( explode( ',', $ip )[0] );
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}
		}
		return '';
	}
}
