<?php
/**
 * REST API controller for assignments
 *
 * Handles CRUD operations for assignments via the REST API.
 *
 * @package PressPrimer_Assignment
 * @subpackage API
 * @since 1.0.0
 */

// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * REST API assignments controller
 *
 * Registers and handles REST API routes for assignment management.
 *
 * Routes:
 * - GET    /ppa/v1/assignments          List assignments
 * - POST   /ppa/v1/assignments          Create assignment
 * - GET    /ppa/v1/assignments/{id}     Get single assignment
 * - PUT    /ppa/v1/assignments/{id}     Update assignment
 * - DELETE /ppa/v1/assignments/{id}     Delete assignment
 *
 * @since 1.0.0
 */
class PressPrimer_Assignment_REST_Assignments {

	/**
	 * API namespace
	 *
	 * @since 1.0.0
	 * @var string
	 */
	const API_NAMESPACE = 'ppa/v1';

	/**
	 * Initialize REST routes
	 *
	 * Hooks into rest_api_init to register routes.
	 *
	 * @since 1.0.0
	 */
	public function init() {
		add_action( 'rest_api_init', [ $this, 'register_routes' ] );
	}

	/**
	 * Register REST API routes
	 *
	 * @since 1.0.0
	 */
	public function register_routes() {
		// Collection routes: list and create.
		register_rest_route(
			self::API_NAMESPACE,
			'/assignments',
			[
				[
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => [ $this, 'get_items' ],
					'permission_callback' => [ $this, 'check_permission' ],
					'args'                => $this->get_collection_params(),
				],
				[
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => [ $this, 'create_item' ],
					'permission_callback' => [ $this, 'check_permission' ],
				],
			]
		);

		// Single item routes: get, update, delete.
		register_rest_route(
			self::API_NAMESPACE,
			'/assignments/(?P<id>\d+)',
			[
				[
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => [ $this, 'get_item' ],
					'permission_callback' => [ $this, 'check_permission' ],
				],
				[
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => [ $this, 'update_item' ],
					'permission_callback' => [ $this, 'check_permission' ],
				],
				[
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => [ $this, 'delete_item' ],
					'permission_callback' => [ $this, 'check_permission' ],
				],
			]
		);

		// Duplicate route. Permission check is per-request (depends on the
		// source assignment's visibility), so it has its own callback.
		register_rest_route(
			self::API_NAMESPACE,
			'/assignments/(?P<id>\d+)/duplicate',
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'duplicate_item' ],
				'permission_callback' => [ $this, 'check_duplicate_permission' ],
			]
		);
	}

	/**
	 * Check if current user has permission
	 *
	 * Allows access for users with manage_own (teachers/instructors) or
	 * manage_all (admins) capability. Data scoping happens in callbacks.
	 *
	 * @since 1.0.0
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return bool True if user has permission.
	 */
	public function check_permission( $request ) {
		return current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN )
			|| current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_ALL );
	}

	/**
	 * Get the author ID filter for the current user
	 *
	 * Returns null for admins (see all data) or the current
	 * user's ID for teachers (see only their own assignments).
	 *
	 * @since 1.0.0
	 *
	 * @return int|null Author ID or null for unrestricted access.
	 */
	private function get_author_id() {
		if ( current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_ALL ) ) {
			return null;
		}
		return get_current_user_id();
	}

	/**
	 * Check if the current user can access a specific assignment
	 *
	 * Returns true for admins. For manage_own users, checks that
	 * the assignment's author_id matches the current user.
	 *
	 * @since 1.0.0
	 *
	 * @param PressPrimer_Assignment_Assignment $assignment Assignment instance.
	 * @return bool True if user can access the assignment.
	 */
	private function can_access_assignment( $assignment ) {
		if ( current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_ALL ) ) {
			return true;
		}
		return (int) $assignment->author_id === get_current_user_id();
	}

	/**
	 * Get collection parameters for list endpoint
	 *
	 * @since 1.0.0
	 *
	 * @return array Collection parameters.
	 */
	public function get_collection_params() {
		return [
			'page'     => [
				'description' => __( 'Current page of the collection.', 'pressprimer-assignment' ),
				'type'        => 'integer',
				'default'     => 1,
				'minimum'     => 1,
			],
			'per_page' => [
				'description' => __( 'Maximum number of items to return.', 'pressprimer-assignment' ),
				'type'        => 'integer',
				'default'     => 20,
				'minimum'     => 1,
				'maximum'     => 100,
			],
			'search'   => [
				'description' => __( 'Search term for filtering.', 'pressprimer-assignment' ),
				'type'        => 'string',
				'default'     => '',
			],
			'status'   => [
				'description' => __( 'Filter by assignment status.', 'pressprimer-assignment' ),
				'type'        => 'string',
				'enum'        => [ 'draft', 'published', 'archived', '' ],
				'default'     => '',
			],
			'order_by' => [
				'description' => __( 'Field to order by.', 'pressprimer-assignment' ),
				'type'        => 'string',
				'default'     => 'created_at',
				'enum'        => [ 'id', 'title', 'status', 'created_at', 'updated_at' ],
			],
			'order'    => [
				'description' => __( 'Sort direction.', 'pressprimer-assignment' ),
				'type'        => 'string',
				'default'     => 'DESC',
				'enum'        => [ 'ASC', 'DESC' ],
			],
			'category' => [
				'description' => __( 'Filter by category ID.', 'pressprimer-assignment' ),
				'type'        => 'integer',
				'default'     => 0,
			],
		];
	}

	/**
	 * Get list of assignments
	 *
	 * @since 1.0.0
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return WP_REST_Response Response object.
	 */
	public function get_items( $request ) {
		$page        = absint( $request->get_param( 'page' ) ) ?: 1;
		$per_page    = absint( $request->get_param( 'per_page' ) ) ?: 20;
		$per_page    = min( $per_page, 100 );
		$search      = sanitize_text_field( $request->get_param( 'search' ) ?? '' );
		$status      = sanitize_text_field( $request->get_param( 'status' ) ?? '' );
		$order_by    = sanitize_text_field( $request->get_param( 'order_by' ) ?? 'created_at' );
		$order       = sanitize_text_field( $request->get_param( 'order' ) ?? 'DESC' );
		$category_id = absint( $request->get_param( 'category' ) );

		// Validate order_by against allowed fields.
		$allowed_order_by = [ 'id', 'title', 'status', 'created_at', 'updated_at' ];
		if ( ! in_array( $order_by, $allowed_order_by, true ) ) {
			$order_by = 'created_at';
		}

		// Validate order direction.
		$order = strtoupper( $order );
		if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
			$order = 'DESC';
		}

		// Build query args.
		$args = [
			'order_by' => $order_by,
			'order'    => $order,
			'limit'    => $per_page,
			'offset'   => ( $page - 1 ) * $per_page,
			'where'    => [],
		];

		if ( $status && in_array( $status, [ 'draft', 'published', 'archived' ], true ) ) {
			$args['where']['status'] = $status;
		}

		// Scope to current user's assignments for manage_own users.
		$author_id = $this->get_author_id();
		if ( null !== $author_id ) {
			$args['where']['author_id'] = $author_id;
		}

		// Handle category filter with a JOIN query.
		if ( $category_id > 0 ) {
			$items = $this->get_assignments_by_category( $category_id, $search, $args, $author_id );
			$total = $this->count_assignments_by_category( $category_id, $search, $args, $author_id );
		} elseif ( $search ) {
			// Handle search with direct database query for LIKE support.
			$items = $this->search_assignments( $search, $args, $author_id );
			$total = $this->count_search_assignments( $search, $args, $author_id );
		} else {
			$items = PressPrimer_Assignment_Assignment::find( $args );
			$total = PressPrimer_Assignment_Assignment::count( $args['where'] );
		}

		$data = array_map( [ $this, 'prepare_item_for_response' ], $items );

		return rest_ensure_response(
			[
				'items' => $data,
				'total' => $total,
				'page'  => $page,
				'pages' => (int) ceil( $total / $per_page ),
			]
		);
	}

	/**
	 * Search assignments by title
	 *
	 * Uses LIKE query for search functionality.
	 *
	 * @since 1.0.0
	 *
	 * @param string   $search    Search term.
	 * @param array    $args      Query arguments.
	 * @param int|null $author_id Author ID for ownership scoping, or null for all.
	 * @return array Array of assignment instances.
	 */
	private function search_assignments( $search, $args, $author_id = null ) {
		global $wpdb;

		$table      = $wpdb->prefix . 'ppa_assignments';
		$like_term  = '%' . $wpdb->esc_like( $search ) . '%';
		$has_status = ! empty( $args['where']['status'] );
		$status     = $has_status ? $args['where']['status'] : '';
		$offset     = absint( $args['offset'] );
		$limit      = absint( $args['limit'] );
		$is_asc     = 'ASC' === strtoupper( $args['order'] );

		// Validate order_by field.
		$order_by_field   = $args['order_by'];
		$allowed_order_by = [ 'id', 'title', 'status', 'created_at', 'updated_at' ];
		if ( ! in_array( $order_by_field, $allowed_order_by, true ) ) {
			$order_by_field = 'created_at';
		}

		// Build dynamic WHERE parts with params array.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$where  = 'WHERE title LIKE %s';
		$params = [ $like_term ];

		if ( $has_status ) {
			$where   .= ' AND status = %s';
			$params[] = $status;
		}

		if ( null !== $author_id ) {
			$where   .= ' AND author_id = %d';
			$params[] = $author_id;
		}

		if ( $is_asc ) {
			$where .= ' ORDER BY %i ASC LIMIT %d, %d';
		} else {
			$where .= ' ORDER BY %i DESC LIMIT %d, %d';
		}

		$params[] = $order_by_field;
		$params[] = $offset;
		$params[] = $limit;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results(
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Array param count is dynamic.
			$wpdb->prepare(
				"SELECT * FROM {$table} {$where}", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$params
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		$results = [];
		if ( $rows ) {
			foreach ( $rows as $row ) {
				$results[] = PressPrimer_Assignment_Assignment::from_row( $row );
			}
		}

		return $results;
	}

	/**
	 * Count search results
	 *
	 * @since 1.0.0
	 *
	 * @param string   $search    Search term.
	 * @param array    $args      Query arguments.
	 * @param int|null $author_id Author ID for ownership scoping, or null for all.
	 * @return int Total count.
	 */
	private function count_search_assignments( $search, $args, $author_id = null ) {
		global $wpdb;

		$table     = $wpdb->prefix . 'ppa_assignments';
		$like_term = '%' . $wpdb->esc_like( $search ) . '%';

		// Build dynamic WHERE with params array.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$where  = 'WHERE title LIKE %s';
		$params = [ $like_term ];

		if ( ! empty( $args['where']['status'] ) ) {
			$where   .= ' AND status = %s';
			$params[] = $args['where']['status'];
		}

		if ( null !== $author_id ) {
			$where   .= ' AND author_id = %d';
			$params[] = $author_id;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		return (int) $wpdb->get_var(
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Array param count is dynamic.
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} {$where}", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$params
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Get a single assignment
	 *
	 * @since 1.0.0
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return WP_REST_Response|WP_Error Response object or error.
	 */
	public function get_item( $request ) {
		$id         = absint( $request->get_param( 'id' ) );
		$assignment = PressPrimer_Assignment_Assignment::get( $id );

		if ( ! $assignment ) {
			return new WP_Error(
				'pressprimer_assignment_not_found',
				__( 'Assignment not found.', 'pressprimer-assignment' ),
				[ 'status' => 404 ]
			);
		}

		if ( ! $this->can_access_assignment( $assignment ) ) {
			return new WP_Error(
				'pressprimer_assignment_forbidden',
				__( 'You do not have permission to access this assignment.', 'pressprimer-assignment' ),
				[ 'status' => 403 ]
			);
		}

		return rest_ensure_response( $this->prepare_item_for_response( $assignment ) );
	}

	/**
	 * Create a new assignment
	 *
	 * @since 1.0.0
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return WP_REST_Response|WP_Error Response object or error.
	 */
	public function create_item( $request ) {
		$data = $this->sanitize_assignment_data( $request );

		if ( is_wp_error( $data ) ) {
			return $data;
		}

		// Set author to current user.
		$data['author_id'] = get_current_user_id();

		$assignment_id = PressPrimer_Assignment_Assignment::create( $data );

		if ( is_wp_error( $assignment_id ) ) {
			return new WP_Error(
				$assignment_id->get_error_code(),
				$assignment_id->get_error_message(),
				[ 'status' => 400 ]
			);
		}

		$assignment = PressPrimer_Assignment_Assignment::get( $assignment_id );

		// Handle category assignment.
		$categories = $request->get_param( 'categories' );
		if ( is_array( $categories ) ) {
			$assignment->set_categories( array_map( 'absint', $categories ) );
		}

		// Clear dashboard statistics cache (assignment count changed).
		if ( class_exists( 'PressPrimer_Assignment_Statistics_Service' ) ) {
			PressPrimer_Assignment_Statistics_Service::clear_all_caches();
		}

		/**
		 * Fire audit log event for assignment created.
		 *
		 * @since 2.0.0
		 */
		do_action(
			'pressprimer_assignment_log_event',
			'assignment.created',
			'assignment',
			$assignment_id,
			[
				'title'     => $assignment->title,
				'author_id' => $assignment->author_id,
			]
		);

		return rest_ensure_response( $this->prepare_item_for_response( $assignment ) );
	}

	/**
	 * Update an existing assignment
	 *
	 * @since 1.0.0
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return WP_REST_Response|WP_Error Response object or error.
	 */
	public function update_item( $request ) {
		$id         = absint( $request->get_param( 'id' ) );
		$assignment = PressPrimer_Assignment_Assignment::get( $id );

		if ( ! $assignment ) {
			return new WP_Error(
				'pressprimer_assignment_not_found',
				__( 'Assignment not found.', 'pressprimer-assignment' ),
				[ 'status' => 404 ]
			);
		}

		if ( ! $this->can_access_assignment( $assignment ) ) {
			return new WP_Error(
				'pressprimer_assignment_forbidden',
				__( 'You do not have permission to edit this assignment.', 'pressprimer-assignment' ),
				[ 'status' => 403 ]
			);
		}

		$data = $this->sanitize_assignment_data( $request );

		if ( is_wp_error( $data ) ) {
			return $data;
		}

		// Track which fields changed for the audit log.
		$old_status     = $assignment->status;
		$changed_fields = array_keys( $data );

		// Update assignment properties.
		foreach ( $data as $key => $value ) {
			if ( property_exists( $assignment, $key ) ) {
				$assignment->$key = $value;
			}
		}

		$result = $assignment->save();

		if ( is_wp_error( $result ) ) {
			return new WP_Error(
				$result->get_error_code(),
				$result->get_error_message(),
				[ 'status' => 400 ]
			);
		}

		// Handle category assignment.
		$categories = $request->get_param( 'categories' );
		if ( is_array( $categories ) ) {
			$assignment->set_categories( array_map( 'absint', $categories ) );
		}

		// Clear dashboard statistics cache (status or other stats-relevant fields may have changed).
		if ( class_exists( 'PressPrimer_Assignment_Statistics_Service' ) ) {
			PressPrimer_Assignment_Statistics_Service::clear_all_caches();
		}

		// Fire audit log events based on what changed.
		$new_status = $assignment->status;

		if ( $old_status !== $new_status && 'published' === $new_status ) {
			/**
			 * Fires when an assignment is published.
			 *
			 * @since 2.0.0
			 *
			 * @param int                            $id         Assignment ID.
			 * @param PressPrimer_Assignment_Assignment $assignment The assignment object.
			 */
			do_action( 'pressprimer_assignment_published', $id, $assignment );

			do_action(
				'pressprimer_assignment_log_event',
				'assignment.published',
				'assignment',
				$id,
				[ 'title' => $assignment->title ]
			);
		} elseif ( $old_status !== $new_status && 'archived' === $new_status ) {
			/**
			 * Fires when an assignment is archived.
			 *
			 * @since 2.0.0
			 *
			 * @param int                            $id         Assignment ID.
			 * @param PressPrimer_Assignment_Assignment $assignment The assignment object.
			 */
			do_action( 'pressprimer_assignment_archived', $id, $assignment );

			do_action(
				'pressprimer_assignment_log_event',
				'assignment.archived',
				'assignment',
				$id,
				[ 'title' => $assignment->title ]
			);
		}

		// Fire audit log event for assignment updated.
		do_action(
			'pressprimer_assignment_log_event',
			'assignment.updated',
			'assignment',
			$id,
			[
				'changed_fields' => $changed_fields,
				'title'          => $assignment->title,
			]
		);

		return rest_ensure_response( $this->prepare_item_for_response( $assignment ) );
	}

	/**
	 * Delete an assignment
	 *
	 * @since 1.0.0
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return WP_REST_Response|WP_Error Response object or error.
	 */
	public function delete_item( $request ) {
		$id         = absint( $request->get_param( 'id' ) );
		$assignment = PressPrimer_Assignment_Assignment::get( $id );

		if ( ! $assignment ) {
			return new WP_Error(
				'pressprimer_assignment_not_found',
				__( 'Assignment not found.', 'pressprimer-assignment' ),
				[ 'status' => 404 ]
			);
		}

		if ( ! $this->can_access_assignment( $assignment ) ) {
			return new WP_Error(
				'pressprimer_assignment_forbidden',
				__( 'You do not have permission to delete this assignment.', 'pressprimer-assignment' ),
				[ 'status' => 403 ]
			);
		}

		// Capture title before deletion for audit log.
		$assignment_title = $assignment->title;

		/**
		 * Fires before an assignment is deleted.
		 *
		 * @since 1.0.0
		 *
		 * @param PressPrimer_Assignment_Assignment $assignment The assignment being deleted.
		 */
		do_action( 'pressprimer_assignment_before_delete', $assignment );

		$result = $assignment->delete();

		if ( is_wp_error( $result ) ) {
			return new WP_Error(
				$result->get_error_code(),
				$result->get_error_message(),
				[ 'status' => 500 ]
			);
		}

		/**
		 * Fires after an assignment is deleted.
		 *
		 * @since 1.0.0
		 *
		 * @param int $id The deleted assignment ID.
		 */
		do_action( 'pressprimer_assignment_deleted', $id );

		/**
		 * Fire audit log event for assignment deleted.
		 *
		 * Enterprise addon listens to this and writes to the audit log.
		 * When Enterprise is not active, this hook fires harmlessly.
		 *
		 * @since 2.0.0
		 *
		 * @param string $event_type  Event identifier.
		 * @param string $object_type Object type affected.
		 * @param int    $object_id   Object ID.
		 * @param array  $data        Additional context.
		 */
		do_action(
			'pressprimer_assignment_log_event',
			'assignment.deleted',
			'assignment',
			$id,
			[
				'title' => $assignment_title,
			]
		);

		return rest_ensure_response(
			[
				'deleted' => true,
				'id'      => $id,
			]
		);
	}

	/**
	 * Permission check for the duplicate endpoint.
	 *
	 * Allows admins (manage_all) and any user with manage_own whose visibility
	 * scope (per the pressprimer_assignment_visible_user_ids filter) covers the
	 * source assignment's author. Without the Educator addon, the visibility
	 * filter returns null and the manage_own branch effectively requires the
	 * source to be authored by the current user.
	 *
	 * @since 2.1.0
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return bool|WP_Error True if permitted; WP_Error otherwise.
	 */
	public function check_duplicate_permission( $request ) {
		if ( current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_ALL ) ) {
			return true;
		}

		if ( ! current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN ) ) {
			return false;
		}

		$id     = absint( $request->get_param( 'id' ) );
		$source = PressPrimer_Assignment_Assignment::get( $id );

		if ( ! $source ) {
			// Defer to the handler so the response is 404, not 403.
			return true;
		}

		// The source's author must be in the current user's visibility scope.
		// Educator hooks pressprimer_assignment_visible_user_ids to enforce
		// teacher data isolation; without it, the filter returns null and
		// we fall back to "is the source authored by me?".
		$visible_user_ids = apply_filters(
			'pressprimer_assignment_visible_user_ids',
			null,
			get_current_user_id()
		);

		if ( null === $visible_user_ids ) {
			return (int) $source->author_id === get_current_user_id();
		}

		if ( empty( $visible_user_ids ) ) {
			return false;
		}

		return in_array( (int) $source->author_id, array_map( 'absint', $visible_user_ids ), true );
	}

	/**
	 * Duplicate an assignment.
	 *
	 * Creates a new draft assignment with all settings and taxonomy copied from
	 * the source. Submissions and files are not copied. Fires the
	 * pressprimer_assignment_assignment_duplicated action so addons can copy
	 * their own per-assignment data.
	 *
	 * @since 2.1.0
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return WP_REST_Response|WP_Error Response on success; WP_Error on failure.
	 */
	public function duplicate_item( $request ) {
		$source_id = absint( $request->get_param( 'id' ) );

		$result = PressPrimer_Assignment_Assignment::duplicate( $source_id );

		if ( is_wp_error( $result ) ) {
			$status = 500;
			if ( 'not_found' === $result->get_error_code() ) {
				$status = 404;
			}
			return new WP_Error(
				$result->get_error_code(),
				$result->get_error_message(),
				[ 'status' => $status ]
			);
		}

		$new_id     = (int) $result;
		$new_assign = PressPrimer_Assignment_Assignment::get( $new_id );

		$response = rest_ensure_response(
			[
				'id'       => $new_id,
				'uuid'     => $new_assign ? $new_assign->uuid : '',
				'edit_url' => add_query_arg(
					[
						'page'       => 'pressprimer-assignment-assignments',
						'action'     => 'edit',
						'assignment' => $new_id,
					],
					admin_url( 'admin.php' )
				),
			]
		);

		$response->set_status( 201 );

		return $response;
	}

	/**
	 * Sanitize assignment data from request
	 *
	 * Extracts and sanitizes all assignment fields from the request.
	 *
	 * @since 1.0.0
	 * @since 2.2.0 Can return WP_Error for invalid due dates, late
	 *              policies, or late penalty schedules.
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return array|WP_Error Sanitized assignment data, or WP_Error.
	 */
	private function sanitize_assignment_data( $request ) {
		$data = [];

		// Text fields.
		if ( null !== $request->get_param( 'title' ) ) {
			$data['title'] = sanitize_text_field( $request->get_param( 'title' ) );
		}

		// Rich text fields - allow safe HTML.
		if ( null !== $request->get_param( 'description' ) ) {
			$data['description'] = wp_kses_post( wp_unslash( $request->get_param( 'description' ) ) );
		}

		if ( null !== $request->get_param( 'instructions' ) ) {
			$data['instructions'] = wp_kses_post( wp_unslash( $request->get_param( 'instructions' ) ) );
		}

		if ( null !== $request->get_param( 'grading_guidelines' ) ) {
			$data['grading_guidelines'] = wp_kses_post( wp_unslash( $request->get_param( 'grading_guidelines' ) ) );
		}

		// Numeric fields.
		if ( null !== $request->get_param( 'max_points' ) ) {
			$data['max_points'] = floatval( $request->get_param( 'max_points' ) );
		}

		if ( null !== $request->get_param( 'passing_score' ) ) {
			$data['passing_score'] = floatval( $request->get_param( 'passing_score' ) );
		}

		if ( null !== $request->get_param( 'max_file_size' ) ) {
			$data['max_file_size'] = absint( $request->get_param( 'max_file_size' ) );
		}

		if ( null !== $request->get_param( 'max_files' ) ) {
			$data['max_files'] = absint( $request->get_param( 'max_files' ) );
		}

		if ( null !== $request->get_param( 'max_resubmissions' ) ) {
			$data['max_resubmissions'] = absint( $request->get_param( 'max_resubmissions' ) );
		}

		// Boolean fields stored as integer.
		if ( null !== $request->get_param( 'allow_resubmission' ) ) {
			$data['allow_resubmission'] = $request->get_param( 'allow_resubmission' ) ? 1 : 0;
		}

		if ( null !== $request->get_param( 'ai_auto_grade' ) ) {
			$data['ai_auto_grade'] = $request->get_param( 'ai_auto_grade' ) ? 1 : 0;
		}

		// Enum fields - validate against allowed values.
		if ( null !== $request->get_param( 'status' ) ) {
			$status = sanitize_text_field( $request->get_param( 'status' ) );
			if ( in_array( $status, [ 'draft', 'published', 'archived' ], true ) ) {
				$data['status'] = $status;
			}
		}

		if ( null !== $request->get_param( 'submission_type' ) ) {
			$submission_type = sanitize_text_field( $request->get_param( 'submission_type' ) );
			if ( in_array( $submission_type, [ 'file', 'text', 'either' ], true ) ) {
				$data['submission_type'] = $submission_type;
			}
		}

		// Theme field - validate against allowed themes.
		if ( null !== $request->get_param( 'theme' ) ) {
			$theme = sanitize_key( $request->get_param( 'theme' ) );
			if ( in_array( $theme, [ 'default', 'modern', 'minimal' ], true ) ) {
				$data['theme'] = $theme;
			}
		}

		// Notification email (comma-separated email addresses).
		if ( null !== $request->get_param( 'notification_email' ) ) {
			$raw_emails = sanitize_text_field( $request->get_param( 'notification_email' ) );
			if ( '' === trim( $raw_emails ) ) {
				$data['notification_email'] = null;
			} else {
				// Validate each email address individually.
				$emails = array_map( 'trim', explode( ',', $raw_emails ) );
				$valid  = [];
				foreach ( $emails as $email_addr ) {
					$sanitized_email = sanitize_email( $email_addr );
					if ( is_email( $sanitized_email ) ) {
						$valid[] = $sanitized_email;
					}
				}
				$data['notification_email'] = ! empty( $valid ) ? implode( ', ', $valid ) : null;
			}
		}

		// Due date (2.2): accepted as a site-timezone datetime string,
		// stored in UTC like the other datetime columns. An empty value
		// clears the due date.
		if ( $request->has_param( 'due_at' ) ) {
			$due_at_raw = $request->get_param( 'due_at' );

			if ( null === $due_at_raw || '' === trim( (string) $due_at_raw ) ) {
				$data['due_at'] = null;
			} else {
				$due_at_raw = sanitize_text_field( (string) $due_at_raw );
				// Accept the HTML datetime-local separator too.
				$due_at_raw = str_replace( 'T', ' ', $due_at_raw );

				if ( false === strtotime( $due_at_raw ) ) {
					return new WP_Error(
						'pressprimer_assignment_invalid_due_at',
						__( 'Invalid due date format.', 'pressprimer-assignment' ),
						[ 'status' => 400 ]
					);
				}

				$data['due_at'] = get_gmt_from_date( $due_at_raw );
			}
		}

		// Late policy (2.2) — enum, invalid values rejected.
		if ( null !== $request->get_param( 'late_policy' ) ) {
			$late_policy = sanitize_text_field( $request->get_param( 'late_policy' ) );
			if ( ! in_array( $late_policy, [ 'accept', 'penalty', 'reject' ], true ) ) {
				return new WP_Error(
					'pressprimer_assignment_invalid_late_policy',
					__( 'Invalid late policy. Must be accept, penalty, or reject.', 'pressprimer-assignment' ),
					[ 'status' => 400 ]
				);
			}
			$data['late_policy'] = $late_policy;
		}

		// Late policy config (2.2): validated and rebuilt from the
		// validated numeric fields — the raw client payload is never
		// stored.
		if ( $request->has_param( 'late_penalty_schedule' ) ) {
			$schedule_raw = $request->get_param( 'late_penalty_schedule' );

			if ( null === $schedule_raw || [] === $schedule_raw || '' === $schedule_raw ) {
				$data['late_penalty_schedule_json'] = null;
			} else {
				$schedule = $this->validate_late_penalty_schedule( $schedule_raw );

				if ( is_wp_error( $schedule ) ) {
					return $schedule;
				}

				$data['late_penalty_schedule_json'] = wp_json_encode( $schedule );
			}
		}

		// JSON fields.
		if ( null !== $request->get_param( 'allowed_file_types' ) ) {
			$file_types = $request->get_param( 'allowed_file_types' );
			if ( is_string( $file_types ) ) {
				// Already JSON string from frontend.
				$decoded = json_decode( $file_types, true );
				if ( is_array( $decoded ) ) {
					$data['allowed_file_types'] = wp_json_encode(
						array_map( 'sanitize_text_field', $decoded )
					);
				}
			} elseif ( is_array( $file_types ) ) {
				$data['allowed_file_types'] = wp_json_encode(
					array_map( 'sanitize_text_field', $file_types )
				);
			}
		}

		return $data;
	}

	/**
	 * Validate a late policy config payload
	 *
	 * Enforces the simplified 2.2 model: a single optional penalty
	 * percentage (0–100) and a single optional cutoff (hours > 0), at
	 * least one of which must be present, plus the deduction basis.
	 * Under the 'penalty' policy the editor sends both ("late by up to X,
	 * deduct Y%, closed after X"); under 'accept' it sends only the
	 * cutoff.
	 *
	 * Returns a clean structure built exclusively from the validated
	 * numeric fields, ready for wp_json_encode() — the raw client
	 * payload is never stored.
	 *
	 * @since 2.2.0
	 *
	 * @param mixed $raw Client-supplied config (array expected).
	 * @return array|WP_Error Clean config array or a 400 WP_Error.
	 */
	private function validate_late_penalty_schedule( $raw ) {
		if ( ! is_array( $raw ) ) {
			return new WP_Error(
				'pressprimer_assignment_invalid_schedule',
				__( 'The late policy settings are not valid.', 'pressprimer-assignment' ),
				[ 'status' => 400 ]
			);
		}

		// Penalty: optional, 0–100.
		$penalty     = null;
		$penalty_raw = isset( $raw['penalty_percent'] ) ? $raw['penalty_percent'] : null;

		if ( null !== $penalty_raw && '' !== $penalty_raw ) {
			if ( ! is_numeric( $penalty_raw ) || (float) $penalty_raw < 0 || (float) $penalty_raw > 100 ) {
				return new WP_Error(
					'pressprimer_assignment_invalid_schedule',
					__( 'The late penalty must be between 0 and 100 percent.', 'pressprimer-assignment' ),
					[ 'status' => 400 ]
				);
			}
			$penalty = round( (float) $penalty_raw, 2 );
		}

		// Cutoff: optional, hours > 0.
		$cutoff     = null;
		$cutoff_raw = isset( $raw['cutoff_hours'] ) ? $raw['cutoff_hours'] : null;

		if ( null !== $cutoff_raw && '' !== $cutoff_raw ) {
			if ( ! is_numeric( $cutoff_raw ) || (float) $cutoff_raw <= 0 ) {
				return new WP_Error(
					'pressprimer_assignment_invalid_schedule',
					__( 'The cutoff must be a number of hours greater than zero.', 'pressprimer-assignment' ),
					[ 'status' => 400 ]
				);
			}
			$cutoff = round( (float) $cutoff_raw, 2 );
		}

		if ( null === $penalty && null === $cutoff ) {
			return new WP_Error(
				'pressprimer_assignment_invalid_schedule',
				__( 'The late policy needs a penalty, a cutoff, or both.', 'pressprimer-assignment' ),
				[ 'status' => 400 ]
			);
		}

		// Basis: what the percentage deducts from.
		$basis = isset( $raw['basis'] ) && 'raw_score' === $raw['basis'] ? 'raw_score' : 'max_points';

		return [
			'cutoff_hours'    => $cutoff,
			'penalty_percent' => $penalty,
			'basis'           => $basis,
		];
	}

	/**
	 * Get assignments filtered by category
	 *
	 * Uses a JOIN with the assignment_tax table to filter by category.
	 *
	 * @since 1.0.0
	 *
	 * @param int      $category_id Category ID to filter by.
	 * @param string   $search      Optional search term.
	 * @param array    $args        Query arguments.
	 * @param int|null $author_id   Author ID for ownership scoping, or null for all.
	 * @return array Array of assignment instances.
	 */
	private function get_assignments_by_category( $category_id, $search, $args, $author_id = null ) {
		global $wpdb;

		$table     = $wpdb->prefix . 'ppa_assignments';
		$tax_table = $wpdb->prefix . 'ppa_assignment_tax';
		$offset    = absint( $args['offset'] );
		$limit     = absint( $args['limit'] );
		$is_asc    = 'ASC' === strtoupper( $args['order'] );

		// Validate order_by field.
		$order_by_field   = $args['order_by'];
		$allowed_order_by = [ 'id', 'title', 'status', 'created_at', 'updated_at' ];
		if ( ! in_array( $order_by_field, $allowed_order_by, true ) ) {
			$order_by_field = 'created_at';
		}

		$has_status = ! empty( $args['where']['status'] );
		$status     = $has_status ? $args['where']['status'] : '';
		$has_search = ! empty( $search );
		$like_term  = $has_search ? '%' . $wpdb->esc_like( $search ) . '%' : '';

		// Build the base query parts.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$base_query = "SELECT DISTINCT a.* FROM {$table} a INNER JOIN {$tax_table} t ON a.id = t.assignment_id WHERE t.category_id = %d";

		$params = [ $category_id ];

		if ( $has_search ) {
			$base_query .= ' AND a.title LIKE %s';
			$params[]    = $like_term;
		}

		if ( $has_status ) {
			$base_query .= ' AND a.status = %s';
			$params[]    = $status;
		}

		if ( null !== $author_id ) {
			$base_query .= ' AND a.author_id = %d';
			$params[]    = $author_id;
		}

		if ( $is_asc ) {
			$base_query .= ' ORDER BY a.%i ASC LIMIT %d, %d';
		} else {
			$base_query .= ' ORDER BY a.%i DESC LIMIT %d, %d';
		}

		$params[] = $order_by_field;
		$params[] = $offset;
		$params[] = $limit;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results(
			$wpdb->prepare( $base_query, $params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		$results = [];
		if ( $rows ) {
			foreach ( $rows as $row ) {
				$results[] = PressPrimer_Assignment_Assignment::from_row( $row );
			}
		}

		return $results;
	}

	/**
	 * Count assignments filtered by category
	 *
	 * @since 1.0.0
	 *
	 * @param int      $category_id Category ID to filter by.
	 * @param string   $search      Optional search term.
	 * @param array    $args        Query arguments.
	 * @param int|null $author_id   Author ID for ownership scoping, or null for all.
	 * @return int Total count.
	 */
	private function count_assignments_by_category( $category_id, $search, $args, $author_id = null ) {
		global $wpdb;

		$table     = $wpdb->prefix . 'ppa_assignments';
		$tax_table = $wpdb->prefix . 'ppa_assignment_tax';

		$has_status = ! empty( $args['where']['status'] );
		$status     = $has_status ? $args['where']['status'] : '';
		$has_search = ! empty( $search );
		$like_term  = $has_search ? '%' . $wpdb->esc_like( $search ) . '%' : '';

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$base_query = "SELECT COUNT(DISTINCT a.id) FROM {$table} a INNER JOIN {$tax_table} t ON a.id = t.assignment_id WHERE t.category_id = %d";

		$params = [ $category_id ];

		if ( $has_search ) {
			$base_query .= ' AND a.title LIKE %s';
			$params[]    = $like_term;
		}

		if ( $has_status ) {
			$base_query .= ' AND a.status = %s';
			$params[]    = $status;
		}

		if ( null !== $author_id ) {
			$base_query .= ' AND a.author_id = %d';
			$params[]    = $author_id;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$count = $wpdb->get_var(
			$wpdb->prepare( $base_query, $params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return (int) $count;
	}

	/**
	 * Prepare assignment for REST response
	 *
	 * Formats assignment data for the API response.
	 *
	 * @since 1.0.0
	 *
	 * @param PressPrimer_Assignment_Assignment $assignment Assignment instance.
	 * @return array Formatted assignment data.
	 */
	private function prepare_item_for_response( $assignment ) {
		// Get assigned categories.
		$categories    = $assignment->get_categories();
		$category_ids  = [];
		$category_data = [];
		foreach ( $categories as $cat ) {
			$category_ids[]  = (int) $cat->id;
			$category_data[] = [
				'id'       => (int) $cat->id,
				'name'     => $cat->name,
				'taxonomy' => $cat->taxonomy,
			];
		}

		$data = [
			'id'                    => (int) $assignment->id,
			'uuid'                  => $assignment->uuid,
			'title'                 => $assignment->title,
			'description'           => $assignment->description,
			'instructions'          => $assignment->instructions,
			'grading_guidelines'    => $assignment->grading_guidelines,
			'max_points'            => (float) $assignment->max_points,
			'passing_score'         => (float) $assignment->passing_score,
			'allow_resubmission'    => (int) $assignment->allow_resubmission,
			'max_resubmissions'     => (int) $assignment->max_resubmissions,
			// Decoded to an array — the raw property is the JSON string
			// stored in the column; consumers get the same shape they send.
			'allowed_file_types'    => ( null !== $assignment->allowed_file_types && '' !== $assignment->allowed_file_types )
				? $assignment->get_allowed_file_types()
				: null,
			'max_file_size'         => (int) $assignment->max_file_size,
			'max_files'             => (int) $assignment->max_files,
			'submission_type'       => $assignment->submission_type,
			// Due date in both site-local time (for editors/display) and
			// UTC (the stored value); null when no due date is set.
			'due_at'                => $assignment->due_at ? get_date_from_gmt( $assignment->due_at ) : null,
			'due_at_gmt'            => $assignment->due_at ? $assignment->due_at : null,
			'late_policy'           => $assignment->late_policy,
			// Decoded, normalized schedule (tiers, cutoff_hours, basis) or null.
			'late_penalty_schedule' => $assignment->get_late_penalty_schedule(),
			'status'                => $assignment->status,
			'ai_auto_grade'         => (int) $assignment->ai_auto_grade,
			'author_id'             => (int) $assignment->author_id,
			'notification_email'    => $assignment->notification_email ?? '',
			'submission_count'      => (int) $assignment->submission_count,
			'graded_count'          => (int) $assignment->graded_count,
			'categories'            => $category_ids,
			'category_details'      => $category_data,
			'created_at'            => $assignment->created_at,
			'updated_at'            => $assignment->updated_at,
		];

		/**
		 * Filters the assignment REST response data.
		 *
		 * @since 1.0.0
		 *
		 * @param array                              $data       Response data.
		 * @param PressPrimer_Assignment_Assignment   $assignment Assignment instance.
		 */
		return apply_filters( 'pressprimer_assignment_rest_response', $data, $assignment );
	}
}
