<?php
/**
 * KooKoa Folders — Settings & preferences AJAX handlers.
 *
 * Persist plugin settings, the per-user sidebar width and last-opened folder,
 * and the per-user review / notice dismissal flags. Registered and dispatched
 * by KookoaWPF_Ajax.
 *
 * @package KookoaWPF
 * @license GPL-2.0-or-later
 */

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

/**
 * AJAX handlers for settings persistence and per-user preference/dismissal flags.
 */
class KookoaWPF_Ajax_Settings extends KookoaWPF_Ajax_Handler {

	/**
	 * Singleton instance.
	 *
	 * @var self|null
	 */
	private static $instance = null;

	/**
	 * Return the singleton instance.
	 *
	 * @return self
	 */
	public static function instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Constructor. The action map lives on the KookoaWPF_Ajax dispatcher, so
	 * this collaborator registers nothing of its own.
	 */
	private function __construct() {
		// Registration is owned by KookoaWPF_Ajax (the dispatch surface).
	}

	/**
	 * Save plugin settings
	 */
	public function save_settings(): void {
		check_ajax_referer( 'kookoawpf_nonce', 'nonce' );
		$this->verify_admin_request();

		$post_types = isset( $_POST['post_types'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['post_types'] ) ) : array();
		// Whitelist against actually registered public post types so an admin
		// can't accidentally save garbage that would create orphan taxonomies.
		$valid_pts   = array_keys( get_post_types( array( 'public' => true ) ) );
		$valid_pts[] = 'attachment'; // attachment is non-public but always supported.
		$post_types  = array_values( array_intersect( $post_types, $valid_pts ) );
		$show_count  = isset( $_POST['show_count'] ) && sanitize_text_field( wp_unslash( $_POST['show_count'] ) ) === 'yes' ? 'yes' : 'no';

		// In FREE the bottom-of-sidebar stats panel is always visible —
		// the toggle UI is only rendered by extensions through the
		// kookoawpf_settings_folder_stats_row action hook. settings.js
		// posts an empty value when the checkbox isn't on the page,
		// which used to silently disable the panel; force 'yes' so the
		// counter survives every save.
		$show_folder_stats = 'yes';

		$width         = isset( $_POST['sidebar_width'] ) ? max( 300, min( 600, absint( wp_unslash( $_POST['sidebar_width'] ) ) ) ) : 300;
		$color         = isset( $_POST['default_color'] ) ? sanitize_hex_color( wp_unslash( $_POST['default_color'] ) ) : '#39FF14';
		$font_size     = isset( $_POST['font_size'] ) ? sanitize_key( wp_unslash( $_POST['font_size'] ) ) : 'default';
		$keep_data     = isset( $_POST['keep_data'] ) && sanitize_text_field( wp_unslash( $_POST['keep_data'] ) ) === 'yes' ? 'yes' : 'no';
		$force_english = isset( $_POST['force_english'] ) && sanitize_text_field( wp_unslash( $_POST['force_english'] ) ) === 'yes' ? 'yes' : 'no';

		// Validate font size.
		if ( ! in_array( $font_size, array( 'compact', 'default', 'large', 'xlarge' ), true ) ) {
			$font_size = 'default';
		}

		update_option( 'kookoawpf_enabled_post_types', $post_types );
		$show_breadcrumb       = isset( $_POST['show_breadcrumb'] ) && sanitize_text_field( wp_unslash( $_POST['show_breadcrumb'] ) ) === 'yes' ? 'yes' : 'no';
		$breadcrumb_style      = isset( $_POST['breadcrumb_style'] ) && sanitize_text_field( wp_unslash( $_POST['breadcrumb_style'] ) ) === 'retro' ? 'retro' : 'default';
		$show_subfolder_cards  = isset( $_POST['show_subfolder_cards'] ) && sanitize_text_field( wp_unslash( $_POST['show_subfolder_cards'] ) ) === 'yes' ? 'yes' : 'no';
		$show_metadata_tooltip = isset( $_POST['show_metadata_tooltip'] ) && sanitize_text_field( wp_unslash( $_POST['show_metadata_tooltip'] ) ) === 'yes' ? 'yes' : 'no';
		$show_exif             = isset( $_POST['show_exif'] ) && sanitize_text_field( wp_unslash( $_POST['show_exif'] ) ) === 'yes' ? 'yes' : 'no';

		update_option( 'kookoawpf_show_item_count', $show_count );
		update_option( 'kookoawpf_show_folder_stats', $show_folder_stats );
		update_option( 'kookoawpf_show_breadcrumb', $show_breadcrumb );
		update_option( 'kookoawpf_breadcrumb_style', $breadcrumb_style );
		update_option( 'kookoawpf_show_subfolder_cards', $show_subfolder_cards );
		update_option( 'kookoawpf_show_metadata_tooltip', $show_metadata_tooltip );
		update_option( 'kookoawpf_show_exif', $show_exif );
		update_option( 'kookoawpf_sidebar_width', $width );
		// kookoawpf_get_sidebar_width() resolves user_meta before the
		// site-wide option, so an admin who has ever drag-resized the
		// sidebar would never see Settings → Sidebar width take effect on
		// their own screen. Sync the current admin's user_meta to the
		// value they just typed so the change is immediately visible.
		// Other users keep their personal drag preference untouched.
		update_user_meta( get_current_user_id(), 'kookoawpf_sidebar_width', $width );
		update_option( 'kookoawpf_folder_color', $color );
		update_option( 'kookoawpf_tree_font_size', $font_size );
		update_option( 'kookoawpf_keep_data_on_uninstall', $keep_data );
		update_option( 'kookoawpf_force_english', $force_english );

		// Keyboard shortcuts toggle.
		$shortcuts_enabled = isset( $_POST['shortcuts_enabled'] ) && sanitize_text_field( wp_unslash( $_POST['shortcuts_enabled'] ) ) === 'yes' ? 'yes' : 'no';
		update_option( 'kookoawpf_shortcuts_enabled', $shortcuts_enabled );

		// Context Menu toggles.
		$all_ctx_keys = array( 'subfolder', 'rename', 'color', 'icon', 'duplicate', 'copy', 'cut', 'paste', 'move', 'pin', 'lock', 'download', 'edit-rules', 'delete' );
		$ctx_enabled  = isset( $_POST['ctx_enabled'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['ctx_enabled'] ) ) : $all_ctx_keys;
		// subfolder, rename, and delete are always enabled.
		foreach ( array( 'subfolder', 'rename', 'delete' ) as $forced ) {
			if ( ! in_array( $forced, $ctx_enabled, true ) ) {
				$ctx_enabled[] = $forced;
			}
		}
		$ctx_disabled = array_values( array_diff( $all_ctx_keys, $ctx_enabled ) );
		update_option( 'kookoawpf_ctx_disabled', $ctx_disabled );

		// Custom Colors (max 6 hex values).
		$custom_colors = isset( $_POST['custom_colors'] ) ? array_map( 'sanitize_text_field', (array) wp_unslash( $_POST['custom_colors'] ) ) : array();
		$custom_colors = array_slice( $custom_colors, 0, 6 );
		$custom_colors = array_map( 'sanitize_hex_color', $custom_colors );
		$custom_colors = array_values( array_filter( $custom_colors ) );
		update_option( 'kookoawpf_custom_colors', $custom_colors );

		// Icon Blocks toggles.
		$all_icon_keys = array( 'folders', 'images', 'documents', 'video-audio', 'tech', 'actions', 'states', 'special' );
		$icon_enabled  = isset( $_POST['icon_blocks_enabled'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['icon_blocks_enabled'] ) ) : $all_icon_keys;
		// folders is always enabled.
		if ( ! in_array( 'folders', $icon_enabled, true ) ) {
			$icon_enabled[] = 'folders';
		}
		$icon_disabled = array_values( array_diff( $all_icon_keys, $icon_enabled ) );
		update_option( 'kookoawpf_icon_blocks_disabled', $icon_disabled );

		// Smart Startup Folder (per-user).
		$startup_mode = isset( $_POST['startup_mode'] ) ? sanitize_key( wp_unslash( $_POST['startup_mode'] ) ) : 'all';
		if ( ! in_array( $startup_mode, array( 'all', 'last', 'specific' ), true ) ) {
			$startup_mode = 'all';
		}
		$startup_ids = array();
		if ( isset( $_POST['startup_folder_ids'] ) ) {
			// Unslash + sanitize the value in a single expression, then run
			// the type check on the LOCAL copy — no raw $_POST left in
			// is_array(). map_deep returns the value unchanged for non-arrays;
			// the is_array() guard below skips the iteration in that case
			// (same behaviour as before).
			$startup_ids_raw = map_deep( wp_unslash( $_POST['startup_folder_ids'] ), 'sanitize_text_field' );
			if ( is_array( $startup_ids_raw ) ) {
				$supported_pts = kookoawpf()->get_supported_post_types();
				foreach ( $startup_ids_raw as $pt_key => $val ) {
					$pt_key = sanitize_key( $pt_key );
					$fid    = absint( $val );
					if ( ! $pt_key || ! $fid ) {
						continue;
					}
					// Scope the post_type to ones the plugin actually
					// manages, then validate the folder really lives in
					// that taxonomy. Without this guard the user_meta
					// could end up holding stale or cross-taxonomy IDs
					// that no longer resolve to a folder.
					if ( ! in_array( $pt_key, $supported_pts, true ) ) {
						continue;
					}
					$startup_tax = KookoaWPF_Folders::get_taxonomy( $pt_key );
					if ( ! $startup_tax || ! term_exists( $fid, $startup_tax ) ) {
						continue;
					}
					$startup_ids[ $pt_key ] = $fid;
				}
			}
		}
		$user_id = get_current_user_id();
		if ( $user_id ) {
			update_user_meta( $user_id, 'kookoawpf_startup_mode', $startup_mode );
			update_user_meta( $user_id, 'kookoawpf_startup_folder_ids', $startup_ids );
		}

		// Access Control — allowed roles.
		$allowed_roles = isset( $_POST['allowed_roles'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['allowed_roles'] ) ) : array();
		// Administrator is always allowed.
		if ( ! in_array( 'administrator', $allowed_roles, true ) ) {
			$allowed_roles[] = 'administrator';
		}
		// Only keep valid WordPress roles.
		$valid_roles   = array_keys( wp_roles()->roles );
		$allowed_roles = array_values( array_intersect( $allowed_roles, $valid_roles ) );
		update_option( 'kookoawpf_allowed_roles', $allowed_roles );

		/**
		 * Extension point: companion plugins can persist their own settings
		 * during the same AJAX round-trip.
		 *
		 * The payload is built from an explicit allowlist of $_POST keys —
		 * the full super-global is never forwarded to consumers. Each value
		 * is unslashed and pre-sanitised as text (sanitize_text_field on
		 * scalars, map_deep on nested arrays). Consumers that need a
		 * different type (HTML, integer, boolean, hex colour, …) MUST apply
		 * the correct type-specific sanitiser on top of the text-sanitised
		 * value before persisting it.
		 *
		 * Extensions that need their own keys in the payload MUST register
		 * them through the `kookoawpf_save_settings_payload_keys` filter,
		 * or fall back to their own dedicated AJAX endpoint.
		 *
		 * @param array<string, mixed> $payload Unslashed, text-sanitised values
		 *                                      for the allowlisted keys only.
		 */
		$default_payload_keys = array(
			// PRO Media (Replace / Editor / WebP / AVIF) — consumed by
			// KookoaWPF_Media_Pro::save_settings().
			'replace_enabled',
			'editor_enabled',
			'webp_enabled',
			'webp_q_low',
			'webp_q_medium',
			'webp_q_high',
			'converter_format',
		);

		/**
		 * Filter: register extra $_POST keys for the settings payload.
		 *
		 * Each entry is run through sanitize_key() before use, so only
		 * plain key-style strings (a-z, 0-9, _, -) survive.
		 *
		 * @param string[] $keys Allowlisted $_POST keys for the payload.
		 */
		$payload_keys = (array) apply_filters( 'kookoawpf_save_settings_payload_keys', $default_payload_keys );

		$settings_payload = array();
		foreach ( $payload_keys as $payload_key ) {
			$payload_key = sanitize_key( (string) $payload_key );
			if ( '' === $payload_key || ! isset( $_POST[ $payload_key ] ) ) {
				continue;
			}
			// map_deep handles arrays AND scalars with the same callback —
			// nested arrays recurse, scalar values just get sanitize_text_field
			// applied directly. That keeps unslash + sanitize on a single
			// expression and lets us skip a separate is_array() branch on
			// the raw $_POST entry.
			$settings_payload[ $payload_key ] = map_deep( wp_unslash( $_POST[ $payload_key ] ), 'sanitize_text_field' );
		}
		do_action( 'kookoawpf_save_settings', $settings_payload );

		wp_send_json_success( array( 'message' => __( 'Settings saved.', 'kookoa-folders' ) ) );
	}

	/**
	 * Save the current user's preferred sidebar width (drag-resize).
	 *
	 * Stored as user meta so any role in the RBAC allowlist can adjust
	 * its own sidebar without holding manage_options. The site-wide
	 * default lives in the kookoawpf_sidebar_width option, written from
	 * Settings → General by admins; this endpoint never touches it.
	 */
	public function save_sidebar_width(): void {
		check_ajax_referer( 'kookoawpf_nonce', 'nonce' );
		$this->verify_request();

		$width = isset( $_POST['width'] ) ? absint( wp_unslash( $_POST['width'] ) ) : 0;

		if ( $width >= 300 && $width <= 600 ) {
			update_user_meta( get_current_user_id(), 'kookoawpf_sidebar_width', $width );
			wp_send_json_success();
		}

		wp_send_json_error( array( 'message' => __( 'Width must be between 300 and 600 pixels.', 'kookoa-folders' ) ) );
	}

	/**
	 * Save the "last opened folder" for the current user and post type.
	 * Used by the Smart Startup Folder "Last opened folder" mode.
	 */
	public function save_last_folder(): void {
		check_ajax_referer( 'kookoawpf_nonce', 'nonce' );
		$this->verify_request();

		$post_type = self::require_managed_post_type(
			isset( $_POST['post_type'] ) ? sanitize_key( wp_unslash( $_POST['post_type'] ) ) : ''
		);
		$folder_id = isset( $_POST['folder_id'] ) ? absint( wp_unslash( $_POST['folder_id'] ) ) : 0;

		if ( ! $post_type ) {
			wp_send_json_error( array( 'message' => __( 'Missing post type.', 'kookoa-folders' ) ) );
		}

		// Enforce post_type is one of the plugin-enabled ones.
		// Defensive: if the option got saved empty (e.g. a settings save where
		// the Media row was rendered as <input type="hidden"> and the JS
		// serializer only picked :checked controls), fall back to the
		// supported list so we don't lock the user out of the feature.
		$enabled = (array) get_option( 'kookoawpf_enabled_post_types', array( 'attachment' ) );
		if ( empty( $enabled ) ) {
			$enabled = kookoawpf()->get_supported_post_types();
		}
		if ( ! in_array( $post_type, $enabled, true ) ) {
			wp_send_json_error( array( 'message' => __( 'Post type not managed.', 'kookoa-folders' ) ) );
		}

		// Validate folder belongs to the taxonomy for this post type (0 means "All Files").
		if ( $folder_id > 0 ) {
			$taxonomy = KookoaWPF_Folders::get_taxonomy( $post_type );
			$term     = get_term( $folder_id, $taxonomy );
			if ( ! $term || is_wp_error( $term ) ) {
				wp_send_json_error( array( 'message' => __( 'Invalid folder.', 'kookoa-folders' ) ) );
			}
		}

		$user_id = get_current_user_id();
		if ( ! $user_id ) {
			wp_send_json_error( array( 'message' => __( 'No user.', 'kookoa-folders' ) ) );
		}

		update_user_meta( $user_id, 'kookoawpf_last_folder_' . $post_type, $folder_id );
		wp_send_json_success();
	}

	/**
	 * Persist the "user dismissed the Rate Us card" flag in user_meta.
	 *
	 * The flag is per-user (not per-site) because review intent is
	 * personal. Once set, the General Settings tab stops rendering
	 * the review request for that user; other admins on the same
	 * site keep seeing it until they dismiss it themselves.
	 *
	 * Set-only — never cleared from the codebase. The user can wipe
	 * it manually from the database if they want the card back.
	 */
	public function dismiss_review(): void {
		check_ajax_referer( 'kookoawpf_nonce', 'nonce' );
		$this->verify_request();

		$user_id = get_current_user_id();
		if ( ! $user_id ) {
			wp_send_json_error( array( 'message' => __( 'No user.', 'kookoa-folders' ) ) );
		}

		update_user_meta( $user_id, 'kookoawpf_review_dismissed', '1' );
		wp_send_json_success();
	}

	/**
	 * Persist the "user clicked the Rate this plugin link" flag.
	 *
	 * Used by the General tab's Rate Us card to reveal the dismiss "×"
	 * only after the user has at least clicked through to WordPress.org.
	 * The intent is a soft gate: don't let people hide the request
	 * without first visiting the rate page. Per-user flag, set-only.
	 */
	public function mark_review_clicked(): void {
		check_ajax_referer( 'kookoawpf_nonce', 'nonce' );
		$this->verify_request();

		$user_id = get_current_user_id();
		if ( ! $user_id ) {
			wp_send_json_error( array( 'message' => __( 'No user.', 'kookoa-folders' ) ) );
		}

		update_user_meta( $user_id, 'kookoawpf_review_clicked', '1' );
		wp_send_json_success();
	}

	/**
	 * Generic per-user "dismiss admin notice" handler.
	 *
	 * The notice id arrives in `$_POST['notice']` (already sanitised as
	 * a slug-style key) and is mapped to the user_meta key
	 * `kookoawpf_notice_{notice}_dismissed`. Allowlist-driven: only
	 * notice ids registered by the FREE plugin are honoured, so a
	 * compromised admin can't write arbitrary user_meta keys.
	 *
	 * Set-only — the user's choice persists indefinitely. Manual DB
	 * removal is the only way to bring a dismissed notice back.
	 */
	public function dismiss_notice(): void {
		check_ajax_referer( 'kookoawpf_nonce', 'nonce' );
		$this->verify_request();

		$notice  = isset( $_POST['notice'] ) ? sanitize_key( wp_unslash( $_POST['notice'] ) ) : '';
		$allowed = array( 'trial_invite', 'trial_discount', 'review_dashboard' );
		if ( ! in_array( $notice, $allowed, true ) ) {
			wp_send_json_error( array( 'message' => __( 'Unknown notice.', 'kookoa-folders' ) ) );
		}

		$user_id = get_current_user_id();
		if ( ! $user_id ) {
			wp_send_json_error( array( 'message' => __( 'No user.', 'kookoa-folders' ) ) );
		}

		update_user_meta( $user_id, 'kookoawpf_notice_' . $notice . '_dismissed', '1' );
		wp_send_json_success();
	}
}
