<?php
/**
 * KooKoa Folders — shared base for AJAX handler classes.
 *
 * The AJAX surface is split into one collaborator class per domain
 * (Folders, Meta, Tree, Items, Settings, Dynamic, Migration). They all
 * share the same request-authentication and post-type validation helpers,
 * which live here so each collaborator can reuse them through inheritance.
 *
 * KookoaWPF_Ajax remains the registration/dispatch surface: it owns the
 * wp_ajax_* action map and delegates each handler to the matching
 * collaborator instance.
 *
 * @package KookoaWPF
 * @license GPL-2.0-or-later
 */

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

/**
 * Base class with the capability/role checks and post-type guards shared by
 * every AJAX handler collaborator.
 *
 * Capability contract (audited 2026-05-31, branch security/ajax-cap-audit).
 * Every public AJAX handler authenticates in three layers:
 *
 *   1. Nonce: check_ajax_referer( 'kookoawpf_nonce', 'nonce' ), kept inline
 *      as each handler's first statement.
 *   2. Capability + RBAC, one of:
 *        - verify_request()        upload_files + allowed-roles (RBAC)
 *        - verify_admin_request()  manage_options
 *      The allowed-roles list defaults to administrator-only; an admin opts
 *      extra roles in from Settings -> Access Control. manage_options skips
 *      the RBAC list because it already gates above any custom role.
 *   3. Object scope, per surface:
 *        - post_type   require_managed_post_type()
 *        - folder term folder_in_taxonomy() / get_term( $taxonomy )
 *        - item        current_user_can( 'edit_post', $id )
 *
 * Surface -> gate map:
 *   - verify_request: Folders CRUD (create/rename/delete/move/duplicate),
 *     Tree (get_tree/sort_folders/get_folder_list), Items (+ per-item
 *     edit_post), Meta (color/icon/lock/pin), Dynamic (create/update/get
 *     rules), plus the per-user preference writes in Settings
 *     (save_sidebar_width/save_last_folder/dismiss_review/
 *     mark_review_clicked/dismiss_notice) and Migration
 *     (dismiss_sidebar_migrate_hint).
 *   - verify_admin_request: save_settings and the Migration importers
 *     (run/dismiss/reset/hide_section); deactivate_source_plugin also
 *     requires deactivate_plugins. Export/Import (KookoaWPF_Export) gate
 *     on manage_options + RBAC inline.
 *   - Download (KookoaWPF_Ajax_Download): upload_files + RBAC + per-
 *     attachment edit_post, all checked inline.
 *
 * Why upload_files (not manage_options) for folder mutations: folders are
 * terms of a private, SHARED taxonomy with no per-term owner, so the object
 * check is taxonomy membership, not ownership. sort_folders writes a
 * site-level option at this same floor on purpose -- a shared, low-stakes,
 * fully reversible view preference, consistent with order/pin which also
 * write shared folder state.
 */
abstract class KookoaWPF_Ajax_Handler {

	/**
	 * Verify upload-level capability + RBAC role for an AJAX handler.
	 *
	 * Each public handler verifies its nonce inline as its first statement;
	 * this helper covers only the capability/role check that is identical
	 * across handlers. The nonce check intentionally stays at the call site,
	 * so every endpoint clearly authenticates its own request.
	 */
	protected function verify_request(): void {
		if ( ! current_user_can( 'upload_files' ) ) {
			wp_send_json_error( array( 'message' => __( 'Permission denied.', 'kookoa-folders' ) ) );
		}

		// Access Control — restrict by allowed roles.
		$allowed_roles = kookoawpf_get_allowed_roles();
		$user          = wp_get_current_user();
		$has_access    = false;
		foreach ( $user->roles as $role ) {
			if ( in_array( $role, $allowed_roles, true ) ) {
				$has_access = true;
				break;
			}
		}
		if ( ! $has_access ) {
			wp_send_json_error( array( 'message' => __( 'Permission denied.', 'kookoa-folders' ) ) );
		}
	}

	/**
	 * Verify admin-level capability for settings handlers. No RBAC role check
	 * because manage_options already gates higher than any custom role list.
	 * Nonce check is kept INLINE in each public handler (first statement).
	 */
	protected function verify_admin_request(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Permission denied.', 'kookoa-folders' ) ) );
		}
	}

	/**
	 * Validate that an incoming post_type belongs to a managed taxonomy.
	 *
	 * The caller passes the value already unslashed and sanitized with
	 * sanitize_key(); this helper only adds the membership check against
	 * kookoawpf()->get_supported_post_types(). On failure (empty or unknown
	 * slug) it sends a localized JSON error and terminates the request.
	 *
	 * @param string $post_type Sanitized post_type slug from the request.
	 * @return string The validated post_type slug.
	 */
	protected static function require_managed_post_type( string $post_type ): string {
		if ( '' === $post_type
			|| ! in_array( $post_type, kookoawpf()->get_supported_post_types(), true )
		) {
			wp_send_json_error( array( 'message' => __( 'Post type not managed.', 'kookoa-folders' ) ) );
		}
		return $post_type;
	}

	/**
	 * Check that a folder term belongs to the kookoawpf taxonomy for a given post type.
	 *
	 * @param int    $folder_id Term ID.
	 * @param string $post_type Post type slug.
	 * @return bool
	 */
	protected static function folder_in_taxonomy( $folder_id, $post_type ) {
		$folder_id = absint( $folder_id );
		if ( ! $folder_id || empty( $post_type ) ) {
			return false;
		}
		$taxonomy = KookoaWPF_Folders::get_taxonomy( $post_type );
		// term_exists returns null|int|array — cast explicitly so future refactors
		// that drop the absint() above don't turn a null into a truthy value.
		return $taxonomy ? (bool) term_exists( $folder_id, $taxonomy ) : false;
	}
}
