<?php
/**
 * Settings Manager
 *
 * The sole interface between the plugin and the WordPress options table.
 * All reads and writes for named configurations, role assignments, and
 * general settings go through this class.
 *
 * @package Agency_Pulse_Admin_Menu
 * @since   1.0.0
 */

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

class Agency_Pulse_Admin_Menu_Settings_Manager {

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

	/**
	 * In-memory cache of general plugin settings for the current request.
	 *
	 * @var array
	 */
	private $settings = array();

	/**
	 * @return Agency_Pulse_Admin_Menu_Settings_Manager
	 */
	public static function get_instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Constructor. Runs legacy migration, ensures a default config exists,
	 * then loads general settings.
	 */
	private function __construct() {
		$this->maybe_migrate_legacy_config();
		$this->maybe_create_default_config();
		$this->load_settings();
	}

	/** @ignore */
	private function __clone() {}

	// -------------------------------------------------------------------------
	// Named Configuration CRUD
	// -------------------------------------------------------------------------

	/**
	 * Return all saved named configurations, keyed by config ID.
	 *
	 * @since 1.0.0
	 * @return array Associative array: config_id => [ id, name, created, items[] ]
	 */
	public function get_all_configs(): array {
		$configs = get_option( Agency_Pulse_Admin_Menu_Constants::OPTION_CONFIGS, array() );
		return is_array( $configs ) ? $configs : array();
	}

	/**
	 * Return one configuration's full record by ID.
	 *
	 * @since 1.0.0
	 * @param string $config_id
	 * @return array|null Full record array, or null if not found.
	 */
	public function get_config( string $config_id ): ?array {
		$configs = $this->get_all_configs();
		return isset( $configs[ $config_id ] ) ? $configs[ $config_id ] : null;
	}

	/**
	 * Return only the items array for a given configuration ID.
	 *
	 * @since 1.0.0
	 * @param string $config_id
	 * @return array Items array, or empty array if config not found or has no items.
	 */
	public function get_config_items( string $config_id ): array {
		$config = $this->get_config( $config_id );
		if ( null === $config ) {
			return array();
		}
		return isset( $config['items'] ) && is_array( $config['items'] )
			? $config['items']
			: array();
	}

	/**
	 * Create a new named configuration.
	 *
	 * @since 1.0.0
	 * @param string $name  Display name for the configuration.
	 * @param array  $items Optional starting items array.
	 * @return string The newly generated config ID.
	 */
	public function create_config( string $name, array $items = array() ): string {
		$configs    = $this->get_all_configs();
		$id         = 'cfg_' . uniqid();
		$configs[ $id ] = array(
			'id'      => $id,
			'name'    => $name,
			'created' => gmdate( 'Y-m-d' ),
			'items'   => $items,
		);
		update_option( Agency_Pulse_Admin_Menu_Constants::OPTION_CONFIGS, $configs );
		return $id;
	}

	/**
	 * Rename an existing configuration.
	 *
	 * @since 1.0.0
	 * @param string $config_id
	 * @param string $new_name
	 * @return bool False if config not found.
	 */
	public function rename_config( string $config_id, string $new_name ): bool {
		$configs = $this->get_all_configs();
		if ( ! isset( $configs[ $config_id ] ) ) {
			return false;
		}
		$configs[ $config_id ]['name'] = $new_name;
		return (bool) update_option( Agency_Pulse_Admin_Menu_Constants::OPTION_CONFIGS, $configs );
	}

	/**
	 * Duplicate an existing configuration under a new name.
	 *
	 * Deep-clones the source items array into a new record.
	 *
	 * @since 1.0.0
	 * @param string $config_id  Source configuration ID.
	 * @param string $new_name   Name for the new configuration.
	 * @return string|null New config ID on success, null if source not found.
	 */
	public function duplicate_config( string $config_id, string $new_name ): ?string {
		$source = $this->get_config( $config_id );
		if ( null === $source ) {
			return null;
		}
		return $this->create_config( $new_name, $source['items'] );
	}

	/**
	 * Overwrite the items array for an existing configuration.
	 *
	 * @since 1.0.0
	 * @param string $config_id
	 * @param array  $items     Sanitized items array.
	 * @return bool False if config not found.
	 */
	public function save_config_items( string $config_id, array $items ): bool {
		$configs = $this->get_all_configs();
		if ( ! isset( $configs[ $config_id ] ) ) {
			return false;
		}
		$configs[ $config_id ]['items'] = $items;

		$result = update_option( Agency_Pulse_Admin_Menu_Constants::OPTION_CONFIGS, $configs );

		// update_option() returns false both on DB error AND when the new value is
		// identical to the stored value (no change needed). The no-change case is
		// a success: the desired state is already persisted.
		if ( false === $result ) {
			$stored = get_option( Agency_Pulse_Admin_Menu_Constants::OPTION_CONFIGS );
			$result = ( $stored === $configs );
		}

		if ( $result ) {
			do_action( Agency_Pulse_Admin_Menu_Constants::HOOK_MENU_CONFIG_SAVED, $config_id, $items );
		}
		return $result;
	}

	/**
	 * Delete a configuration by ID.
	 *
	 * Deletion is blocked when:
	 *  - The config ID does not exist.
	 *  - It is the only remaining configuration (must always have at least one).
	 *  - It is currently assigned to one or more WordPress roles.
	 *
	 * The AJAX handler is responsible for determining which condition caused the
	 * false return and providing an appropriate error message to the user.
	 *
	 * @since 1.0.0
	 * @param string $config_id
	 * @return bool True on successful deletion, false if blocked.
	 */
	public function delete_config( string $config_id ): bool {
		$configs = $this->get_all_configs();

		if ( ! isset( $configs[ $config_id ] ) ) {
			return false;
		}

		if ( count( $configs ) <= 1 ) {
			return false;
		}

		if ( in_array( $config_id, $this->get_role_assignments(), true ) ) {
			return false;
		}

		unset( $configs[ $config_id ] );
		return (bool) update_option( Agency_Pulse_Admin_Menu_Constants::OPTION_CONFIGS, $configs );
	}

	/**
	 * Atomically replace all configurations and role assignments.
	 *
	 * Used by the import and cloud-pull handlers to restore a complete backup.
	 * Both options are written regardless of whether the incoming data is
	 * identical to what is stored — the caller has already validated and
	 * sanitized the data.
	 *
	 * @since 1.0.0
	 * @param array $configs          Full configs array keyed by config ID.
	 * @param array $role_assignments Role-slug => config-ID map.
	 * @return bool True only when both option writes succeed.
	 */
	public function replace_all( array $configs, array $role_assignments ): bool {
		$configs_ok      = update_option( Agency_Pulse_Admin_Menu_Constants::OPTION_CONFIGS, $configs );
		$assignments_ok  = update_option( Agency_Pulse_Admin_Menu_Constants::OPTION_ROLE_ASSIGNMENTS, $role_assignments );

		// update_option() returns false on both DB error and no-change. Treat
		// no-change as success by verifying the stored value matches what we sent.
		if ( false === $configs_ok ) {
			$configs_ok = ( get_option( Agency_Pulse_Admin_Menu_Constants::OPTION_CONFIGS ) === $configs );
		}
		if ( false === $assignments_ok ) {
			$assignments_ok = ( get_option( Agency_Pulse_Admin_Menu_Constants::OPTION_ROLE_ASSIGNMENTS ) === $role_assignments );
		}

		return $configs_ok && $assignments_ok;
	}

	// -------------------------------------------------------------------------
	// Role Assignments
	// -------------------------------------------------------------------------

	/**
	 * Return the full role-to-config-ID assignment map.
	 *
	 * @since 1.0.0
	 * @return array Associative array: role_slug => config_id
	 */
	public function get_role_assignments(): array {
		$assignments = get_option( Agency_Pulse_Admin_Menu_Constants::OPTION_ROLE_ASSIGNMENTS, array() );
		return is_array( $assignments ) ? $assignments : array();
	}

	/**
	 * Persist the full role-to-config-ID assignment map.
	 *
	 * Validation of role slugs and config IDs is the responsibility of the
	 * calling AJAX handler before invoking this method.
	 *
	 * @since 1.0.0
	 * @param array $assignments role_slug => config_id
	 * @return bool
	 */
	public function save_role_assignments( array $assignments ): bool {
		return (bool) update_option( Agency_Pulse_Admin_Menu_Constants::OPTION_ROLE_ASSIGNMENTS, $assignments );
	}

	/**
	 * Return the items array for the configuration assigned to a given role.
	 *
	 * Returns null only when no assignment exists for this role.
	 * Returns an empty array when an assignment exists but the config has no
	 * saved items yet (role is assigned but nothing has been configured).
	 *
	 * @since 1.0.0
	 * @param string $role WordPress role slug.
	 * @return array|null Items array, or null if no assignment for this role.
	 */
	public function get_config_for_role( string $role ): ?array {
		$assignments = $this->get_role_assignments();
		if ( ! isset( $assignments[ $role ] ) ) {
			return null;
		}
		return $this->get_config_items( $assignments[ $role ] );
	}

	/**
	 * Resolve the correct items array for a given WordPress user.
	 *
	 * Iterates the user's roles in their stored order and returns the items
	 * array for the first role that has an assignment. Returns null if no
	 * role has an assignment — the caller should leave the menu unmodified.
	 *
	 * @since 1.0.0
	 * @param WP_User $user
	 * @return array|null Items array, or null if no assignment found.
	 */
	public function get_config_for_user( WP_User $user ): ?array {
		$assignments = $this->get_role_assignments();
		foreach ( $user->roles as $role ) {
			if ( isset( $assignments[ $role ] ) ) {
				return $this->get_config_items( $assignments[ $role ] );
			}
		}
		return null;
	}

	// -------------------------------------------------------------------------
	// General Settings
	// -------------------------------------------------------------------------

	/**
	 * Load general plugin settings from the database into memory.
	 *
	 * @since 1.0.0
	 */
	private function load_settings(): void {
		$saved          = get_option( Agency_Pulse_Admin_Menu_Constants::OPTION_SETTINGS, array() );
		$this->settings = wp_parse_args(
			is_array( $saved ) ? $saved : array(),
			$this->get_defaults()
		);
	}

	/**
	 * Default values for general plugin settings.
	 *
	 * @since 1.0.0
	 * @return array
	 */
	private function get_defaults(): array {
		return array(
			'hide_builder_from_sidebar' => false,
		);
	}

	/**
	 * Get a single general setting value.
	 *
	 * @since 1.0.0
	 * @param string $key
	 * @param mixed  $default Fallback if key is not set.
	 * @return mixed
	 */
	public function get( string $key, $default = null ) {
		return isset( $this->settings[ $key ] ) ? $this->settings[ $key ] : $default;
	}

	public function get_all_settings(): array {
		return $this->settings;
	}

	/**
	 * Persist updated general settings.
	 *
	 * Merges the supplied key/value pairs into the current settings before saving.
	 *
	 * @since 1.0.0
	 * @param array $new_settings
	 * @return bool
	 */
	public function save( array $new_settings ): bool {
		$this->settings = wp_parse_args( $new_settings, $this->settings );
		return (bool) update_option(
			Agency_Pulse_Admin_Menu_Constants::OPTION_SETTINGS,
			$this->settings
		);
	}

	// -------------------------------------------------------------------------
	// First-run initialisation
	// -------------------------------------------------------------------------

	/**
	 * Ensure at least one named configuration exists.
	 *
	 * On a fresh install (no legacy option to migrate, no existing configs) this
	 * creates a "Default" configuration and assigns it to the administrator role
	 * so that:
	 *  - The Menu Builder page immediately shows the full live menu list rather
	 *    than the "No configurations found" placeholder.
	 *  - apply_menu_config() has a resolvable config for the administrator from
	 *    the very first page load after activation.
	 *
	 * Exits immediately on every subsequent request because get_all_configs()
	 * returns a non-empty array after the first run — no extra DB work done.
	 *
	 * @since 1.0.0
	 */
	private function maybe_create_default_config(): void {
		if ( ! empty( $this->get_all_configs() ) ) {
			return;
		}
		$id = $this->create_config( __( 'Default', 'agency-pulse-admin-menu' ) );
		$this->save_role_assignments( array( 'administrator' => $id ) );
	}

	// -------------------------------------------------------------------------
	// Migration
	// -------------------------------------------------------------------------

	/**
	 * One-time migration from the legacy single-config option.
	 *
	 * If the option 'aamo_menu_config' exists (written by the skeleton build
	 * before the named-config data model was introduced), its contents are
	 * migrated into a new named configuration called "Default", that config is
	 * assigned to the administrator role, and the legacy option is deleted.
	 *
	 * This runs on every page load until migration completes, after which the
	 * legacy option no longer exists and this method exits immediately.
	 *
	 * @since 1.0.0
	 */
	private function maybe_migrate_legacy_config(): void {
		$legacy = get_option( Agency_Pulse_Admin_Menu_Constants::OPTION_LEGACY_CONFIG );
		if ( false === $legacy ) {
			return;
		}
		$items = is_array( $legacy ) ? $legacy : array();
		$id    = $this->create_config( 'Default', $items );
		$this->save_role_assignments( array( 'administrator' => $id ) );
		delete_option( Agency_Pulse_Admin_Menu_Constants::OPTION_LEGACY_CONFIG );
	}
}
