<?php

declare( strict_types=1 );

namespace StrykeWP\Modules\Connector;

/**
 * Content Management API — CRUD for Posts, Pages, Media, Terms, Comments, Users & Settings.
 *
 * Provides 18 REST endpoints that allow the STRYKE Hub to fully manage
 * WordPress content without leaving the Hub dashboard.
 *
 * @package StrykeWP\Modules\Connector
 */
class ContentManager {

	/** @var string[] Allowed post types for create/list/update. */
	private const ALLOWED_POST_TYPES = array( 'post', 'page' );

	/** @var string[] Allowed post statuses. */
	private const ALLOWED_STATUSES = array( 'publish', 'draft', 'pending', 'private', 'trash', 'future' );

	/** @var string[] Allowed comment moderation actions. */
	private const MODERATE_ACTIONS = array( 'approve', 'spam', 'trash' );

	/** @var string[] Whitelisted option keys for settings endpoint. */
	private const ALLOWED_SETTINGS = array(
		'blogname',
		'blogdescription',
		'timezone_string',
		'date_format',
		'time_format',
		'posts_per_page',
		'blog_public',
		'default_comment_status',
		'permalink_structure',
	);

	// -----------------------------------------------------------------------
	// Response Builders
	// -----------------------------------------------------------------------

	private function success_response( string $message, ?array $data = null, int $status = 200 ): \WP_REST_Response {
		return new \WP_REST_Response( array( 'success' => true, 'message' => $message, 'data' => $data ), $status );
	}

	private function error_response( string $message, ?array $data = null, int $status = 400 ): \WP_REST_Response {
		return new \WP_REST_Response( array( 'success' => false, 'message' => $message, 'data' => $data ), $status );
	}

	// -----------------------------------------------------------------------
	// Validators (public — used as REST validate_callback)
	// -----------------------------------------------------------------------

	/**
	 * Validates a post ID parameter.
	 *
	 * @param mixed            $value   Raw value.
	 * @param \WP_REST_Request $request Request object.
	 * @param string           $param   Parameter name.
	 * @return bool|\WP_Error
	 */
	public function validate_post_id( mixed $value, \WP_REST_Request $request, string $param ): bool|\WP_Error {
		$id = absint( $value );
		if ( $id < 1 ) {
			return new \WP_Error( 'stryke_invalid_id', __( 'Post ID must be a positive integer.', 'stryke-wp' ), array( 'status' => 400 ) );
		}
		return true;
	}

	/**
	 * Validates a taxonomy parameter.
	 *
	 * @param mixed            $value   Raw value.
	 * @param \WP_REST_Request $request Request object.
	 * @param string           $param   Parameter name.
	 * @return bool|\WP_Error
	 */
	public function validate_taxonomy( mixed $value, \WP_REST_Request $request, string $param ): bool|\WP_Error {
		$tax = sanitize_key( (string) $value );
		if ( ! taxonomy_exists( $tax ) ) {
			return new \WP_Error( 'stryke_invalid_taxonomy', __( 'Taxonomy does not exist.', 'stryke-wp' ), array( 'status' => 400 ) );
		}
		$taxonomy_obj = get_taxonomy( $tax );
		if ( ! $taxonomy_obj || ! $taxonomy_obj->public ) {
			return new \WP_Error( 'stryke_private_taxonomy', __( 'Only public taxonomies are allowed.', 'stryke-wp' ), array( 'status' => 400 ) );
		}
		return true;
	}

	/**
	 * Validates a term ID parameter.
	 *
	 * @param mixed            $value   Raw value.
	 * @param \WP_REST_Request $request Request object.
	 * @param string           $param   Parameter name.
	 * @return bool|\WP_Error
	 */
	public function validate_term_id( mixed $value, \WP_REST_Request $request, string $param ): bool|\WP_Error {
		$id = absint( $value );
		if ( $id < 1 ) {
			return new \WP_Error( 'stryke_invalid_id', __( 'Term ID must be a positive integer.', 'stryke-wp' ), array( 'status' => 400 ) );
		}
		return true;
	}

	/**
	 * Validates a comment ID parameter.
	 *
	 * @param mixed            $value   Raw value.
	 * @param \WP_REST_Request $request Request object.
	 * @param string           $param   Parameter name.
	 * @return bool|\WP_Error
	 */
	public function validate_comment_id( mixed $value, \WP_REST_Request $request, string $param ): bool|\WP_Error {
		$id = absint( $value );
		if ( $id < 1 ) {
			return new \WP_Error( 'stryke_invalid_id', __( 'Comment ID must be a positive integer.', 'stryke-wp' ), array( 'status' => 400 ) );
		}
		return true;
	}

	/**
	 * Validates a media/attachment ID parameter.
	 *
	 * @param mixed            $value   Raw value.
	 * @param \WP_REST_Request $request Request object.
	 * @param string           $param   Parameter name.
	 * @return bool|\WP_Error
	 */
	public function validate_media_id( mixed $value, \WP_REST_Request $request, string $param ): bool|\WP_Error {
		$id = absint( $value );
		if ( $id < 1 ) {
			return new \WP_Error( 'stryke_invalid_id', __( 'Media ID must be a positive integer.', 'stryke-wp' ), array( 'status' => 400 ) );
		}
		return true;
	}

	// =======================================================================
	// POST & PAGE HANDLERS
	// =======================================================================

	/**
	 * GET /stryke/v1/posts — List posts/pages with filtering & pagination.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_posts_list( \WP_REST_Request $request ): \WP_REST_Response {

		$post_type = sanitize_key( $request->get_param( 'post_type' ) ?: 'post' );
		$status    = sanitize_key( $request->get_param( 'status' ) ?: 'any' );
		$per_page  = min( 100, max( 1, absint( $request->get_param( 'per_page' ) ?: 20 ) ) );
		$paged     = max( 1, absint( $request->get_param( 'page' ) ?: 1 ) );
		$search    = sanitize_text_field( $request->get_param( 'search' ) ?: '' );
		$orderby   = sanitize_key( $request->get_param( 'orderby' ) ?: 'date' );
		$order     = strtoupper( sanitize_key( $request->get_param( 'order' ) ?: 'DESC' ) );
		$category  = absint( $request->get_param( 'category' ) ?: 0 );
		$tag       = sanitize_text_field( $request->get_param( 'tag' ) ?: '' );
		$author    = absint( $request->get_param( 'author' ) ?: 0 );

		// Validate post_type.
		if ( 'any' !== $post_type && ! in_array( $post_type, self::ALLOWED_POST_TYPES, true ) ) {
			return $this->error_response(
				sprintf( __( 'Invalid post_type. Allowed: %s, any.', 'stryke-wp' ), implode( ', ', self::ALLOWED_POST_TYPES ) )
			);
		}

		// Validate order.
		if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) {
			$order = 'DESC';
		}

		$query_args = array(
			'post_type'      => 'any' === $post_type ? self::ALLOWED_POST_TYPES : $post_type,
			'post_status'    => 'any' === $status ? self::ALLOWED_STATUSES : $status,
			'posts_per_page' => $per_page,
			'paged'          => $paged,
			'orderby'        => $orderby,
			'order'          => $order,
		);

		if ( '' !== $search ) {
			$query_args['s'] = $search;
		}
		if ( $category > 0 ) {
			$query_args['cat'] = $category;
		}
		if ( '' !== $tag ) {
			$query_args['tag'] = $tag;
		}
		if ( $author > 0 ) {
			$query_args['author'] = $author;
		}

		$query = new \WP_Query( $query_args );
		$posts = array_map( array( $this, 'map_post' ), $query->posts );

		return new \WP_REST_Response(
			array(
				'posts'        => $posts,
				'total'        => $query->found_posts,
				'total_pages'  => $query->max_num_pages,
				'page'         => $paged,
				'per_page'     => $per_page,
				'generated_at' => current_time( 'c' ),
			),
			200
		);
	}

	/**
	 * GET /stryke/v1/posts/{id} — Get a single post/page.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_post_get( \WP_REST_Request $request ): \WP_REST_Response {

		$id   = absint( $request->get_param( 'id' ) );
		$post = get_post( $id );

		if ( ! $post instanceof \WP_Post ) {
			return $this->error_response(
				sprintf( __( 'Post #%d not found.', 'stryke-wp' ), $id ),
				null,
				404
			);
		}

		if ( ! in_array( $post->post_type, self::ALLOWED_POST_TYPES, true ) ) {
			return $this->error_response(
				__( 'This post type is not accessible via this endpoint.', 'stryke-wp' ),
				null,
				403
			);
		}

		return new \WP_REST_Response(
			array(
				'post'         => $this->map_post( $post ),
				'generated_at' => current_time( 'c' ),
			),
			200
		);
	}

	/**
	 * POST /stryke/v1/posts — Create a new post or page.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_post_create( \WP_REST_Request $request ): \WP_REST_Response {

		$post_type = sanitize_key( $request->get_param( 'post_type' ) ?: 'post' );

		if ( ! in_array( $post_type, self::ALLOWED_POST_TYPES, true ) ) {
			return $this->error_response(
				sprintf( __( 'Invalid post_type. Allowed: %s.', 'stryke-wp' ), implode( ', ', self::ALLOWED_POST_TYPES ) )
			);
		}

		$status = sanitize_key( $request->get_param( 'status' ) ?: 'draft' );
		if ( ! in_array( $status, self::ALLOWED_STATUSES, true ) ) {
			$status = 'draft';
		}

		$postarr = array(
			'post_title'   => sanitize_text_field( $request->get_param( 'title' ) ?: '' ),
			'post_content' => wp_kses_post( $request->get_param( 'content' ) ?: '' ),
			'post_excerpt' => sanitize_textarea_field( $request->get_param( 'excerpt' ) ?: '' ),
			'post_status'  => $status,
			'post_type'    => $post_type,
		);

		// Categories (int[]).
		$categories = $request->get_param( 'categories' );
		if ( is_array( $categories ) ) {
			$postarr['post_category'] = array_map( 'absint', $categories );
		}

		// Tags (string[]).
		$tags = $request->get_param( 'tags' );
		if ( is_array( $tags ) ) {
			$postarr['tags_input'] = array_map( 'sanitize_text_field', $tags );
		}

		// Meta (key-value pairs).
		$meta = $request->get_param( 'meta' );

		$result = wp_insert_post( $postarr, true );

		if ( is_wp_error( $result ) ) {
			return $this->error_response( $result->get_error_message(), null, 500 );
		}

		// Featured image.
		$featured_image = absint( $request->get_param( 'featured_image' ) ?: 0 );
		if ( $featured_image > 0 ) {
			set_post_thumbnail( $result, $featured_image );
		}

		// Post meta.
		if ( is_array( $meta ) ) {
			foreach ( $meta as $key => $value ) {
				$safe_key = sanitize_key( $key );
				if ( '' !== $safe_key ) {
					update_post_meta( $result, $safe_key, sanitize_text_field( (string) $value ) );
				}
			}
		}

		$post = get_post( $result );

		return $this->success_response(
			sprintf( __( '%s created successfully.', 'stryke-wp' ), ucfirst( $post_type ) ),
			array( 'post' => $this->map_post( $post ) ),
			201
		);
	}

	/**
	 * PUT /stryke/v1/posts/{id} — Update an existing post or page.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_post_update( \WP_REST_Request $request ): \WP_REST_Response {

		$id   = absint( $request->get_param( 'id' ) );
		$post = get_post( $id );

		if ( ! $post instanceof \WP_Post ) {
			return $this->error_response( sprintf( __( 'Post #%d not found.', 'stryke-wp' ), $id ), null, 404 );
		}

		if ( ! in_array( $post->post_type, self::ALLOWED_POST_TYPES, true ) ) {
			return $this->error_response( __( 'This post type is not accessible via this endpoint.', 'stryke-wp' ), null, 403 );
		}

		$postarr = array( 'ID' => $id );

		$title = $request->get_param( 'title' );
		if ( null !== $title ) {
			$postarr['post_title'] = sanitize_text_field( $title );
		}

		$content = $request->get_param( 'content' );
		if ( null !== $content ) {
			$postarr['post_content'] = wp_kses_post( $content );
		}

		$excerpt = $request->get_param( 'excerpt' );
		if ( null !== $excerpt ) {
			$postarr['post_excerpt'] = sanitize_textarea_field( $excerpt );
		}

		$status = $request->get_param( 'status' );
		if ( null !== $status ) {
			$safe_status = sanitize_key( $status );
			if ( in_array( $safe_status, self::ALLOWED_STATUSES, true ) ) {
				$postarr['post_status'] = $safe_status;
			}
		}

		$categories = $request->get_param( 'categories' );
		if ( is_array( $categories ) ) {
			$postarr['post_category'] = array_map( 'absint', $categories );
		}

		$tags = $request->get_param( 'tags' );
		if ( is_array( $tags ) ) {
			$postarr['tags_input'] = array_map( 'sanitize_text_field', $tags );
		}

		$result = wp_update_post( $postarr, true );

		if ( is_wp_error( $result ) ) {
			return $this->error_response( $result->get_error_message(), null, 500 );
		}

		// Featured image.
		$featured_image = $request->get_param( 'featured_image' );
		if ( null !== $featured_image ) {
			$img_id = absint( $featured_image );
			if ( 0 === $img_id ) {
				delete_post_thumbnail( $id );
			} else {
				set_post_thumbnail( $id, $img_id );
			}
		}

		// Post meta.
		$meta = $request->get_param( 'meta' );
		if ( is_array( $meta ) ) {
			foreach ( $meta as $key => $value ) {
				$safe_key = sanitize_key( $key );
				if ( '' !== $safe_key ) {
					update_post_meta( $id, $safe_key, sanitize_text_field( (string) $value ) );
				}
			}
		}

		$updated_post = get_post( $id );

		return $this->success_response(
			sprintf( __( 'Post #%d updated successfully.', 'stryke-wp' ), $id ),
			array( 'post' => $this->map_post( $updated_post ) )
		);
	}

	/**
	 * DELETE /stryke/v1/posts/{id} — Trash or permanently delete a post.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_post_delete( \WP_REST_Request $request ): \WP_REST_Response {

		$id    = absint( $request->get_param( 'id' ) );
		$force = (bool) $request->get_param( 'force' );
		$post  = get_post( $id );

		if ( ! $post instanceof \WP_Post ) {
			return $this->error_response( sprintf( __( 'Post #%d not found.', 'stryke-wp' ), $id ), null, 404 );
		}

		if ( ! in_array( $post->post_type, self::ALLOWED_POST_TYPES, true ) ) {
			return $this->error_response( __( 'This post type is not accessible via this endpoint.', 'stryke-wp' ), null, 403 );
		}

		if ( $force ) {
			$result = wp_delete_post( $id, true );
		} else {
			$result = wp_trash_post( $id );
		}

		if ( ! $result ) {
			return $this->error_response( sprintf( __( 'Failed to delete post #%d.', 'stryke-wp' ), $id ), null, 500 );
		}

		return $this->success_response(
			sprintf(
				$force
					? __( 'Post #%d permanently deleted.', 'stryke-wp' )
					: __( 'Post #%d moved to trash.', 'stryke-wp' ),
				$id
			),
			array( 'id' => $id, 'deleted' => $force ? 'permanent' : 'trashed' )
		);
	}

	/**
	 * GET /stryke/v1/posts/{id}/revisions — Get revisions for a post.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_post_revisions( \WP_REST_Request $request ): \WP_REST_Response {

		$id   = absint( $request->get_param( 'id' ) );
		$post = get_post( $id );

		if ( ! $post instanceof \WP_Post ) {
			return $this->error_response( sprintf( __( 'Post #%d not found.', 'stryke-wp' ), $id ), null, 404 );
		}

		if ( ! in_array( $post->post_type, self::ALLOWED_POST_TYPES, true ) ) {
			return $this->error_response( __( 'This post type is not accessible via this endpoint.', 'stryke-wp' ), null, 403 );
		}

		$revisions = wp_get_post_revisions( $id );
		$mapped    = array();

		foreach ( $revisions as $rev ) {
			$mapped[] = array(
				'id'            => $rev->ID,
				'author'        => absint( $rev->post_author ),
				'author_name'   => sanitize_text_field( get_the_author_meta( 'display_name', (int) $rev->post_author ) ),
				'date'          => $rev->post_date,
				'date_gmt'      => $rev->post_date_gmt,
				'title'         => sanitize_text_field( $rev->post_title ),
				'content'       => wp_kses_post( $rev->post_content ),
				'excerpt'       => sanitize_textarea_field( $rev->post_excerpt ),
			);
		}

		return new \WP_REST_Response(
			array(
				'post_id'      => $id,
				'revisions'    => $mapped,
				'count'        => count( $mapped ),
				'generated_at' => current_time( 'c' ),
			),
			200
		);
	}

	// =======================================================================
	// MEDIA HANDLERS
	// =======================================================================

	/**
	 * GET /stryke/v1/media — List media attachments.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_media_list( \WP_REST_Request $request ): \WP_REST_Response {

		$per_page  = min( 100, max( 1, absint( $request->get_param( 'per_page' ) ?: 20 ) ) );
		$paged     = max( 1, absint( $request->get_param( 'page' ) ?: 1 ) );
		$search    = sanitize_text_field( $request->get_param( 'search' ) ?: '' );
		$mime_type = sanitize_mime_type( $request->get_param( 'mime_type' ) ?: '' );

		$query_args = array(
			'post_type'      => 'attachment',
			'post_status'    => 'inherit',
			'posts_per_page' => $per_page,
			'paged'          => $paged,
			'orderby'        => 'date',
			'order'          => 'DESC',
		);

		if ( '' !== $search ) {
			$query_args['s'] = $search;
		}
		if ( '' !== $mime_type ) {
			$query_args['post_mime_type'] = $mime_type;
		}

		$query = new \WP_Query( $query_args );
		$media = array_map( array( $this, 'map_media' ), $query->posts );

		return new \WP_REST_Response(
			array(
				'media'        => $media,
				'total'        => $query->found_posts,
				'total_pages'  => $query->max_num_pages,
				'page'         => $paged,
				'per_page'     => $per_page,
				'generated_at' => current_time( 'c' ),
			),
			200
		);
	}

	/**
	 * POST /stryke/v1/media — Upload a media file (base64 encoded).
	 *
	 * Expects JSON body: { file_data: string (base64), filename: string, mime_type: string }
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_media_upload( \WP_REST_Request $request ): \WP_REST_Response {

		$file_data = $request->get_param( 'file_data' );
		$filename  = sanitize_file_name( $request->get_param( 'filename' ) ?: '' );
		$mime_type = sanitize_mime_type( $request->get_param( 'mime_type' ) ?: '' );

		if ( empty( $file_data ) || empty( $filename ) ) {
			return $this->error_response( __( 'file_data and filename are required.', 'stryke-wp' ) );
		}

		// Validate MIME type via WP allowed file types.
		$filetype = wp_check_filetype( $filename );
		if ( ! $filetype['type'] ) {
			return $this->error_response(
				sprintf( __( 'File type not allowed: %s.', 'stryke-wp' ), $filename ),
				null,
				415
			);
		}

		// Decode base64 data.
		$decoded = base64_decode( $file_data, true );
		if ( false === $decoded ) {
			return $this->error_response( __( 'Invalid base64-encoded file_data.', 'stryke-wp' ) );
		}

		// Check against upload size limit.
		$max_upload = wp_max_upload_size();
		if ( strlen( $decoded ) > $max_upload ) {
			return $this->error_response(
				sprintf(
					__( 'File exceeds the maximum upload size (%s).', 'stryke-wp' ),
					size_format( $max_upload )
				),
				null,
				413
			);
		}

		// Write to a temp file for media_handle_sideload.
		$upload_dir = wp_upload_dir();
		$tmp_path   = $upload_dir['basedir'] . '/' . wp_unique_filename( $upload_dir['basedir'], $filename );

		if ( ! function_exists( 'media_handle_sideload' ) ) {
			require_once ABSPATH . 'wp-admin/includes/media.php';
			require_once ABSPATH . 'wp-admin/includes/file.php';
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		$written = file_put_contents( $tmp_path, $decoded );
		if ( false === $written ) {
			return $this->error_response( __( 'Failed to write temporary upload file.', 'stryke-wp' ), null, 500 );
		}

		$file_array = array(
			'name'     => $filename,
			'type'     => $mime_type ?: $filetype['type'],
			'tmp_name' => $tmp_path,
			'error'    => 0,
			'size'     => strlen( $decoded ),
		);

		$attachment_id = media_handle_sideload( $file_array, 0 );

		// Clean up temp file if still present.
		if ( file_exists( $tmp_path ) ) {
			wp_delete_file( $tmp_path );
		}

		if ( is_wp_error( $attachment_id ) ) {
			return $this->error_response( $attachment_id->get_error_message(), null, 500 );
		}

		$post = get_post( $attachment_id );

		return $this->success_response(
			__( 'Media uploaded successfully.', 'stryke-wp' ),
			array( 'media' => $this->map_media( $post ) ),
			201
		);
	}

	/**
	 * DELETE /stryke/v1/media/{id} — Delete a media attachment.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_media_delete( \WP_REST_Request $request ): \WP_REST_Response {

		$id   = absint( $request->get_param( 'id' ) );
		$post = get_post( $id );

		if ( ! $post instanceof \WP_Post || 'attachment' !== $post->post_type ) {
			return $this->error_response( sprintf( __( 'Media #%d not found.', 'stryke-wp' ), $id ), null, 404 );
		}

		$result = wp_delete_attachment( $id, true );

		if ( ! $result ) {
			return $this->error_response( sprintf( __( 'Failed to delete media #%d.', 'stryke-wp' ), $id ), null, 500 );
		}

		return $this->success_response(
			sprintf( __( 'Media #%d deleted permanently.', 'stryke-wp' ), $id ),
			array( 'id' => $id )
		);
	}

	// =======================================================================
	// TERM HANDLERS
	// =======================================================================

	/**
	 * GET /stryke/v1/terms — List terms for a given taxonomy.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_terms_list( \WP_REST_Request $request ): \WP_REST_Response {

		$taxonomy   = sanitize_key( $request->get_param( 'taxonomy' ) ?: 'category' );
		$search     = sanitize_text_field( $request->get_param( 'search' ) ?: '' );
		$parent     = $request->get_param( 'parent' );
		$hide_empty = (bool) ( $request->get_param( 'hide_empty' ) ?? false );
		$per_page   = min( 100, max( 1, absint( $request->get_param( 'per_page' ) ?: 50 ) ) );
		$offset     = max( 0, absint( $request->get_param( 'offset' ) ?: 0 ) );

		$taxonomy_obj = get_taxonomy( $taxonomy );
		if ( ! $taxonomy_obj || ! $taxonomy_obj->public ) {
			return $this->error_response( __( 'Invalid or non-public taxonomy.', 'stryke-wp' ) );
		}

		$args = array(
			'taxonomy'   => $taxonomy,
			'hide_empty' => $hide_empty,
			'number'     => $per_page,
			'offset'     => $offset,
		);

		if ( '' !== $search ) {
			$args['search'] = $search;
		}
		if ( null !== $parent ) {
			$args['parent'] = absint( $parent );
		}

		$terms = get_terms( $args );

		if ( is_wp_error( $terms ) ) {
			return $this->error_response( $terms->get_error_message(), null, 500 );
		}

		$mapped = array_map( array( $this, 'map_term' ), $terms );

		return new \WP_REST_Response(
			array(
				'terms'        => $mapped,
				'taxonomy'     => $taxonomy,
				'count'        => count( $mapped ),
				'generated_at' => current_time( 'c' ),
			),
			200
		);
	}

	/**
	 * POST /stryke/v1/terms — Create a new term.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_term_create( \WP_REST_Request $request ): \WP_REST_Response {

		$taxonomy = sanitize_key( $request->get_param( 'taxonomy' ) ?: 'category' );
		$name     = sanitize_text_field( $request->get_param( 'name' ) ?: '' );
		$slug     = sanitize_title( $request->get_param( 'slug' ) ?: '' );
		$parent   = absint( $request->get_param( 'parent' ) ?: 0 );
		$desc     = sanitize_textarea_field( $request->get_param( 'description' ) ?: '' );

		$taxonomy_obj = get_taxonomy( $taxonomy );
		if ( ! $taxonomy_obj || ! $taxonomy_obj->public ) {
			return $this->error_response( __( 'Invalid or non-public taxonomy.', 'stryke-wp' ) );
		}

		if ( '' === $name ) {
			return $this->error_response( __( 'Term name is required.', 'stryke-wp' ) );
		}

		$args = array( 'description' => $desc );
		if ( '' !== $slug ) {
			$args['slug'] = $slug;
		}
		if ( $parent > 0 && is_taxonomy_hierarchical( $taxonomy ) ) {
			$args['parent'] = $parent;
		}

		$result = wp_insert_term( $name, $taxonomy, $args );

		if ( is_wp_error( $result ) ) {
			return $this->error_response( $result->get_error_message(), null, 409 );
		}

		$term = get_term( $result['term_id'], $taxonomy );

		return $this->success_response(
			sprintf( __( 'Term "%s" created successfully.', 'stryke-wp' ), $name ),
			array( 'term' => $this->map_term( $term ) ),
			201
		);
	}

	/**
	 * PUT /stryke/v1/terms/{id} — Update a term.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_term_update( \WP_REST_Request $request ): \WP_REST_Response {

		$id       = absint( $request->get_param( 'id' ) );
		$taxonomy = sanitize_key( $request->get_param( 'taxonomy' ) ?: 'category' );

		$taxonomy_obj = get_taxonomy( $taxonomy );
		if ( ! $taxonomy_obj || ! $taxonomy_obj->public ) {
			return $this->error_response( __( 'Invalid or non-public taxonomy.', 'stryke-wp' ) );
		}

		$term = get_term( $id, $taxonomy );
		if ( ! $term instanceof \WP_Term ) {
			return $this->error_response( sprintf( __( 'Term #%d not found.', 'stryke-wp' ), $id ), null, 404 );
		}

		$args = array();

		$name = $request->get_param( 'name' );
		if ( null !== $name ) {
			$args['name'] = sanitize_text_field( $name );
		}

		$slug = $request->get_param( 'slug' );
		if ( null !== $slug ) {
			$args['slug'] = sanitize_title( $slug );
		}

		$desc = $request->get_param( 'description' );
		if ( null !== $desc ) {
			$args['description'] = sanitize_textarea_field( $desc );
		}

		$parent = $request->get_param( 'parent' );
		if ( null !== $parent && is_taxonomy_hierarchical( $taxonomy ) ) {
			$args['parent'] = absint( $parent );
		}

		if ( empty( $args ) ) {
			return $this->error_response( __( 'No fields to update.', 'stryke-wp' ) );
		}

		$result = wp_update_term( $id, $taxonomy, $args );

		if ( is_wp_error( $result ) ) {
			return $this->error_response( $result->get_error_message(), null, 500 );
		}

		$updated_term = get_term( $id, $taxonomy );

		return $this->success_response(
			sprintf( __( 'Term #%d updated successfully.', 'stryke-wp' ), $id ),
			array( 'term' => $this->map_term( $updated_term ) )
		);
	}

	/**
	 * DELETE /stryke/v1/terms/{id} — Delete a term.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_term_delete( \WP_REST_Request $request ): \WP_REST_Response {

		$id       = absint( $request->get_param( 'id' ) );
		$taxonomy = sanitize_key( $request->get_param( 'taxonomy' ) ?: 'category' );

		$taxonomy_obj = get_taxonomy( $taxonomy );
		if ( ! $taxonomy_obj || ! $taxonomy_obj->public ) {
			return $this->error_response( __( 'Invalid or non-public taxonomy.', 'stryke-wp' ) );
		}

		$term = get_term( $id, $taxonomy );
		if ( ! $term instanceof \WP_Term ) {
			return $this->error_response( sprintf( __( 'Term #%d not found.', 'stryke-wp' ), $id ), null, 404 );
		}

		$result = wp_delete_term( $id, $taxonomy );

		if ( is_wp_error( $result ) ) {
			return $this->error_response( $result->get_error_message(), null, 500 );
		}

		if ( false === $result ) {
			return $this->error_response( __( 'Cannot delete the default term.', 'stryke-wp' ), null, 409 );
		}

		return $this->success_response(
			sprintf( __( 'Term #%d deleted successfully.', 'stryke-wp' ), $id ),
			array( 'id' => $id, 'taxonomy' => $taxonomy )
		);
	}

	// =======================================================================
	// COMMENT HANDLERS
	// =======================================================================

	/**
	 * GET /stryke/v1/comments — List comments with filtering & pagination.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_comments_list( \WP_REST_Request $request ): \WP_REST_Response {

		$per_page = min( 100, max( 1, absint( $request->get_param( 'per_page' ) ?: 20 ) ) );
		$paged    = max( 1, absint( $request->get_param( 'page' ) ?: 1 ) );
		$status   = sanitize_key( $request->get_param( 'status' ) ?: 'all' );
		$post_id  = absint( $request->get_param( 'post_id' ) ?: 0 );
		$search   = sanitize_text_field( $request->get_param( 'search' ) ?: '' );

		$args = array(
			'number' => $per_page,
			'offset' => ( $paged - 1 ) * $per_page,
			'status' => $status,
		);

		if ( $post_id > 0 ) {
			$args['post_id'] = $post_id;
		}
		if ( '' !== $search ) {
			$args['search'] = $search;
		}

		$comments = get_comments( $args );

		// Get total count for pagination.
		$count_args = $args;
		$count_args['count'] = true;
		unset( $count_args['number'], $count_args['offset'] );
		$total = (int) get_comments( $count_args );

		$mapped = array_map( array( $this, 'map_comment' ), $comments );

		return new \WP_REST_Response(
			array(
				'comments'     => $mapped,
				'total'        => $total,
				'total_pages'  => (int) ceil( $total / $per_page ),
				'page'         => $paged,
				'per_page'     => $per_page,
				'generated_at' => current_time( 'c' ),
			),
			200
		);
	}

	/**
	 * POST /stryke/v1/comments/{id}/moderate — Approve, spam, or trash a comment.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_comment_moderate( \WP_REST_Request $request ): \WP_REST_Response {

		$id     = absint( $request->get_param( 'id' ) );
		$action = sanitize_key( $request->get_param( 'action' ) ?: '' );

		$comment = get_comment( $id );
		if ( ! $comment instanceof \WP_Comment ) {
			return $this->error_response( sprintf( __( 'Comment #%d not found.', 'stryke-wp' ), $id ), null, 404 );
		}

		if ( ! in_array( $action, self::MODERATE_ACTIONS, true ) ) {
			return $this->error_response(
				sprintf( __( 'Invalid moderation action. Allowed: %s.', 'stryke-wp' ), implode( ', ', self::MODERATE_ACTIONS ) )
			);
		}

		$result = wp_set_comment_status( $id, $action );

		if ( ! $result ) {
			return $this->error_response( sprintf( __( 'Failed to moderate comment #%d.', 'stryke-wp' ), $id ), null, 500 );
		}

		return $this->success_response(
			sprintf( __( 'Comment #%d moderated: %s.', 'stryke-wp' ), $id, $action ),
			array( 'id' => $id, 'action' => $action )
		);
	}

	/**
	 * DELETE /stryke/v1/comments/{id} — Delete a comment permanently.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_comment_delete( \WP_REST_Request $request ): \WP_REST_Response {

		$id    = absint( $request->get_param( 'id' ) );
		$force = (bool) $request->get_param( 'force' );

		$comment = get_comment( $id );
		if ( ! $comment instanceof \WP_Comment ) {
			return $this->error_response( sprintf( __( 'Comment #%d not found.', 'stryke-wp' ), $id ), null, 404 );
		}

		$result = wp_delete_comment( $id, $force );

		if ( ! $result ) {
			return $this->error_response( sprintf( __( 'Failed to delete comment #%d.', 'stryke-wp' ), $id ), null, 500 );
		}

		return $this->success_response(
			sprintf(
				$force
					? __( 'Comment #%d permanently deleted.', 'stryke-wp' )
					: __( 'Comment #%d moved to trash.', 'stryke-wp' ),
				$id
			),
			array( 'id' => $id, 'deleted' => $force ? 'permanent' : 'trashed' )
		);
	}

	// =======================================================================
	// USERS HANDLER
	// =======================================================================

	/**
	 * GET /stryke/v1/users — List users (read-only).
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_users_list( \WP_REST_Request $request ): \WP_REST_Response {

		$per_page = min( 100, max( 1, absint( $request->get_param( 'per_page' ) ?: 20 ) ) );
		$paged    = max( 1, absint( $request->get_param( 'page' ) ?: 1 ) );
		$role     = sanitize_key( $request->get_param( 'role' ) ?: '' );
		$search   = sanitize_text_field( $request->get_param( 'search' ) ?: '' );
		$orderby  = sanitize_key( $request->get_param( 'orderby' ) ?: 'registered' );
		$order    = strtoupper( sanitize_key( $request->get_param( 'order' ) ?: 'DESC' ) );

		if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) {
			$order = 'DESC';
		}

		$args = array(
			'number'  => $per_page,
			'paged'   => $paged,
			'orderby' => $orderby,
			'order'   => $order,
		);

		if ( '' !== $role ) {
			$args['role'] = $role;
		}
		if ( '' !== $search ) {
			$args['search']         = '*' . $search . '*';
			$args['search_columns'] = array( 'user_login', 'user_email', 'display_name' );
		}

		$user_query = new \WP_User_Query( $args );
		$users      = array_map( array( $this, 'map_user' ), $user_query->get_results() );
		$total      = $user_query->get_total();

		return new \WP_REST_Response(
			array(
				'users'        => $users,
				'total'        => $total,
				'total_pages'  => (int) ceil( $total / $per_page ),
				'page'         => $paged,
				'per_page'     => $per_page,
				'generated_at' => current_time( 'c' ),
			),
			200
		);
	}

	// =======================================================================
	// SETTINGS HANDLERS
	// =======================================================================

	/**
	 * GET /stryke/v1/settings — Read whitelisted WordPress options.
	 *
	 * @param \WP_REST_Request $request Incoming REST request (unused).
	 * @return \WP_REST_Response
	 */
	public function handle_settings_get( \WP_REST_Request $request ): \WP_REST_Response {

		$settings = array();

		foreach ( self::ALLOWED_SETTINGS as $key ) {
			$settings[ $key ] = get_option( $key, '' );
		}

		return new \WP_REST_Response(
			array(
				'settings'     => $settings,
				'generated_at' => current_time( 'c' ),
			),
			200
		);
	}

	/**
	 * PUT /stryke/v1/settings — Update whitelisted WordPress options.
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response
	 */
	public function handle_settings_update( \WP_REST_Request $request ): \WP_REST_Response {

		$settings = $request->get_param( 'settings' );

		if ( ! is_array( $settings ) || empty( $settings ) ) {
			return $this->error_response( __( 'settings must be a non-empty object with key/value pairs.', 'stryke-wp' ) );
		}

		$updated  = array();
		$rejected = array();

		foreach ( $settings as $key => $value ) {
			$safe_key = sanitize_key( $key );

			if ( ! in_array( $safe_key, self::ALLOWED_SETTINGS, true ) ) {
				$rejected[] = $safe_key;
				continue;
			}

			$safe_value = sanitize_text_field( (string) $value );
			update_option( $safe_key, $safe_value );
			$updated[] = $safe_key;
		}

		// Flush rewrite rules if permalink_structure changed.
		if ( in_array( 'permalink_structure', $updated, true ) ) {
			flush_rewrite_rules();
		}

		return $this->success_response(
			sprintf( __( '%d setting(s) updated.', 'stryke-wp' ), count( $updated ) ),
			array(
				'updated'  => $updated,
				'rejected' => $rejected,
			)
		);
	}

	// =======================================================================
	// DATA MAPPERS (private)
	// =======================================================================

	/**
	 * Maps a WP_Post to a serializable array.
	 *
	 * @param \WP_Post $post WordPress post object.
	 * @return array<string, mixed>
	 */
	private function map_post( \WP_Post $post ): array {

		$thumbnail_id  = (int) get_post_thumbnail_id( $post->ID );
		$thumbnail_url = $thumbnail_id > 0 ? (string) wp_get_attachment_url( $thumbnail_id ) : null;

		$categories = array();
		if ( 'post' === $post->post_type ) {
			$cats = get_the_category( $post->ID );
			if ( is_array( $cats ) ) {
				foreach ( $cats as $cat ) {
					$categories[] = array(
						'id'   => $cat->term_id,
						'name' => sanitize_text_field( $cat->name ),
						'slug' => sanitize_key( $cat->slug ),
					);
				}
			}
		}

		$tags = array();
		if ( 'post' === $post->post_type ) {
			$post_tags = get_the_tags( $post->ID );
			if ( is_array( $post_tags ) ) {
				foreach ( $post_tags as $tag ) {
					$tags[] = array(
						'id'   => $tag->term_id,
						'name' => sanitize_text_field( $tag->name ),
						'slug' => sanitize_key( $tag->slug ),
					);
				}
			}
		}

		return array(
			'id'              => $post->ID,
			'title'           => sanitize_text_field( $post->post_title ),
			'slug'            => sanitize_title( $post->post_name ),
			'content'         => $post->post_content,
			'excerpt'         => sanitize_textarea_field( $post->post_excerpt ),
			'status'          => $post->post_status,
			'post_type'       => $post->post_type,
			'author'          => absint( $post->post_author ),
			'author_name'     => sanitize_text_field( get_the_author_meta( 'display_name', (int) $post->post_author ) ),
			'date'            => $post->post_date,
			'date_gmt'        => $post->post_date_gmt,
			'modified'        => $post->post_modified,
			'modified_gmt'    => $post->post_modified_gmt,
			'permalink'       => (string) get_permalink( $post->ID ),
			'featured_image'  => $thumbnail_url,
			'comment_count'   => absint( $post->comment_count ),
			'categories'      => $categories,
			'tags'            => $tags,
			'elementor'       => array(
				'is_elementor'  => get_post_meta( $post->ID, '_elementor_edit_mode', true ) === 'builder',
				'template_type' => get_post_meta( $post->ID, '_elementor_template_type', true ) ?: null,
				'version'       => get_post_meta( $post->ID, '_elementor_version', true ) ?: null,
			),
		);
	}

	/**
	 * Maps a media WP_Post to a serializable array.
	 *
	 * @param \WP_Post $post WordPress attachment post object.
	 * @return array<string, mixed>
	 */
	private function map_media( \WP_Post $post ): array {

		$metadata = wp_get_attachment_metadata( $post->ID );
		$filesize = 0;
		$width    = 0;
		$height   = 0;

		if ( is_array( $metadata ) ) {
			$width  = absint( $metadata['width']  ?? 0 );
			$height = absint( $metadata['height'] ?? 0 );
			$filesize = absint( $metadata['filesize'] ?? 0 );
		}

		// Fallback filesize from attached file path.
		if ( 0 === $filesize ) {
			$file_path = get_attached_file( $post->ID );
			if ( $file_path && file_exists( $file_path ) ) {
				$filesize = (int) filesize( $file_path );
			}
		}

		return array(
			'id'          => $post->ID,
			'title'       => sanitize_text_field( $post->post_title ),
			'filename'    => sanitize_file_name( basename( (string) get_attached_file( $post->ID ) ) ),
			'url'         => (string) wp_get_attachment_url( $post->ID ),
			'mime_type'   => sanitize_mime_type( $post->post_mime_type ),
			'alt_text'    => sanitize_text_field( (string) get_post_meta( $post->ID, '_wp_attachment_image_alt', true ) ),
			'caption'     => sanitize_textarea_field( $post->post_excerpt ),
			'description' => $post->post_content,
			'date'        => $post->post_date,
			'author'      => absint( $post->post_author ),
			'width'       => $width,
			'height'      => $height,
			'filesize'    => $filesize,
		);
	}

	/**
	 * Maps a WP_Term to a serializable array.
	 *
	 * @param \WP_Term $term WordPress term object.
	 * @return array<string, mixed>
	 */
	private function map_term( \WP_Term $term ): array {
		return array(
			'id'          => $term->term_id,
			'name'        => sanitize_text_field( $term->name ),
			'slug'        => sanitize_key( $term->slug ),
			'taxonomy'    => sanitize_key( $term->taxonomy ),
			'description' => sanitize_textarea_field( $term->description ),
			'parent'      => absint( $term->parent ),
			'count'       => absint( $term->count ),
		);
	}

	/**
	 * Maps a WP_Comment to a serializable array.
	 *
	 * @param \WP_Comment $comment WordPress comment object.
	 * @return array<string, mixed>
	 */
	private function map_comment( \WP_Comment $comment ): array {
		return array(
			'id'           => absint( $comment->comment_ID ),
			'post_id'      => absint( $comment->comment_post_ID ),
			'post_title'   => sanitize_text_field( get_the_title( (int) $comment->comment_post_ID ) ),
			'author_name'  => sanitize_text_field( $comment->comment_author ),
			'author_email' => sanitize_email( $comment->comment_author_email ),
			'author_url'   => esc_url_raw( $comment->comment_author_url ),
			'content'      => wp_kses_post( $comment->comment_content ),
			'date'         => $comment->comment_date,
			'date_gmt'     => $comment->comment_date_gmt,
			'status'       => (string) wp_get_comment_status( $comment ),
			'parent'       => absint( $comment->comment_parent ),
			'type'         => sanitize_key( $comment->comment_type ?: 'comment' ),
		);
	}

	/**
	 * Maps a WP_User to a serializable array (safe — no passwords or sensitive data).
	 *
	 * @param \WP_User $user WordPress user object.
	 * @return array<string, mixed>
	 */
	private function map_user( \WP_User $user ): array {
		return array(
			'id'           => $user->ID,
			'login'        => sanitize_user( $user->user_login ),
			'display_name' => sanitize_text_field( $user->display_name ),
			'email'        => sanitize_email( $user->user_email ),
			'roles'        => $user->roles,
			'registered'   => $user->user_registered,
			'url'          => esc_url_raw( $user->user_url ),
			'post_count'   => absint( count_user_posts( $user->ID ) ),
		);
	}
}
