<?php
/**
 * Plugin Update — server-side execution.
 *
 * Update one or more plugins from WordPress.org. Accepts either a list
 * of specific slugs or `all: true` to update every plugin with an
 * available update. Runs in-process under the App Password user's
 * identity via WP core's `Plugin_Upgrader::bulk_upgrade()`.
 *
 * WP.org compliance posture: same five-check model as PluginInstall:
 *   1. User-bound capability check (`update_plugins`) via App Password.
 *   2. Standard `Plugin_Upgrader` machinery + WP.org-sourced ZIPs.
 *   3. Honors `DISALLOW_FILE_MODS` upfront.
 *   4. Honors `WP_Filesystem` init failure (FTP-mode hosts) with a
 *      clear actionable error.
 *   5. Multisite super-admin requirement — sub-site admin App
 *      Passwords return a precise error, not a silent partial.
 *
 * Verification: `bulk_upgrade()` doesn't throw on per-slug failure —
 * it returns `null` / `WP_Error` in the result slot for that plugin.
 * We compare pre/post `get_plugin_data()['Version']` per slug to catch
 * silent no-ops where the upgrader claimed success but the version
 * didn't advance.
 *
 * @since 0.0.5
 * @package zip-ai
 */

namespace ZipAI\MCP\Classes\Abilities\Zipai\System;

use Plugin_Upgrader;
use WP_Ajax_Upgrader_Skin;
use WP_Error;
use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
use ZipAI\MCP\Classes\Abilities\Zipai\System\PluginResolver;
use ZipAI\MCP\Classes\Core\Response;
use ZipAI\MCP\Classes\Core\Tool_Types;

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

class PluginUpdate extends Abstract_Ability {

	protected $is_destructive = true;

	public function configure() {
		$this->id          = 'zipai/update-plugin';
		$this->label       = 'Update Plugin';
		$this->description = 'Update one or more plugins from WordPress.org. Pass `slugs: [..]` for specific plugins or `all: true` to update every plugin with an available update. '
			. 'Runs in-process under the App-Password user\'s identity via `Plugin_Upgrader::bulk_upgrade()`. '
			. 'Requires `update_plugins` capability (network-admin / super_admin on multisite). '
			. 'Synchronous: response carries the actual per-plugin from→to version transitions. '
			. 'Verifies each upgrade by reading back the plugin\'s Version header — catches silent failures where bulk_upgrade returns null for a slot but doesn\'t throw.';
		$this->capability  = 'update_plugins';

		$this->meta = array(
			'tool_type' => Tool_Types::WRITE,
		);
	}

	public function get_tool_type() {
		return Tool_Types::WRITE;
	}

	public function get_input_schema() {
		return array(
			'type'                 => 'object',
			'additionalProperties' => false,
			'properties'           => array(
				'slugs' => array(
					'type'        => 'array',
					'items'       => array(
						'type'    => 'string',
						'pattern' => '^[a-z0-9][a-z0-9-]*(?:/[a-z0-9._-]+(?:\.php)?)?$',
					),
					'minItems'    => 1,
					'description' => 'Specific plugins to update. Bare slug ("contact-form-7") or WP REST plugin id "folder/file" (`.php` optional). Mutually exclusive with `all`.',
				),
				'all'   => array(
					'type'        => 'boolean',
					'description' => 'When true, update every plugin with an available update. Mutually exclusive with `slugs`.',
				),
			),
		);
	}

	public function execute( $args ) {
		// Hard fail on locked-down sites — same gate PluginInstall uses.
		if ( defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS ) {
			return Response::error( 'Plugin updates are disabled on this site (DISALLOW_FILE_MODS).' );
		}

		// Multisite: plugin updates are a network-admin operation. A
		// sub-site admin's App Password won't have `manage_network_plugins`.
		// Fail loudly + precisely rather than silently no-op'ing every slug.
		if ( is_multisite() && ! is_super_admin() ) {
			return Response::error( 'On multisite, plugin updates require super_admin (network-admin). The connected user lacks that role.' );
		}

		// Discriminate input shape — exactly one of `slugs` / `all` must be set.
		$update_all     = isset( $args['all'] ) && true === $args['all'];
		$explicit_slugs = array();
		if ( isset( $args['slugs'] ) && is_array( $args['slugs'] ) ) {
			foreach ( $args['slugs'] as $s ) {
				if ( is_string( $s ) && '' !== trim( $s ) ) {
					$explicit_slugs[] = trim( $s );
				}
			}
		}

		if ( $update_all && ! empty( $explicit_slugs ) ) {
			return Response::error( 'Pass either `slugs` (specific plugins) or `all: true` — not both.' );
		}
		if ( ! $update_all && empty( $explicit_slugs ) ) {
			return Response::error( 'Pass either `slugs` (array of plugin slugs) or `all: true`.' );
		}

		// Load WP-Admin upgrader stack — not auto-loaded in REST context.
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/misc.php';
		require_once ABSPATH . 'wp-admin/includes/plugin.php';
		require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
		require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
		require_once ABSPATH . 'wp-admin/includes/update.php';

		if ( ! WP_Filesystem() ) {
			return Response::error(
				'Filesystem credentials required for plugin update. '
				. 'Configure FS_METHOD or store FTP credentials in wp-config.php.'
			);
		}

		// Refresh the available-updates transient so the upgrader sees the
		// latest versions on WP.org. WP core caches this for 12 hours by
		// default; without the refresh, "update all" against a fresh
		// transient miss would resolve as "no updates available".
		wp_update_plugins();
		$available = get_site_transient( 'update_plugins' );

		// Resolve target plugin files.
		$plugin_files = array();
		$skipped      = array(); // { slug, plugin_file?, reason } — pre-upgrade filter

		if ( $update_all ) {
			$plugin_files = ( is_object( $available ) && ! empty( $available->response ) )
				? array_keys( (array) $available->response )
				: array();
			if ( empty( $plugin_files ) ) {
				return Response::success( array(
					'updated'           => array(),
					'failed'            => array(),
					'skipped_no_update' => array(),
					'message'           => 'No plugin updates available — every plugin is already at its latest version.',
				) );
			}
		} else {
			foreach ( $explicit_slugs as $slug ) {
				$plugin_file = PluginResolver::resolve_plugin_file( $slug );
				if ( null === $plugin_file ) {
					$skipped[] = array(
						'slug'   => $slug,
						'reason' => 'not installed',
					);
					continue;
				}
				$has_update = is_object( $available )
					&& isset( $available->response )
					&& isset( $available->response[ $plugin_file ] );
				if ( ! $has_update ) {
					$skipped[] = array(
						'slug'        => $slug,
						'plugin_file' => $plugin_file,
						'reason'      => 'already at latest version',
					);
					continue;
				}
				$plugin_files[] = $plugin_file;
			}
			if ( empty( $plugin_files ) ) {
				return Response::success( array(
					'updated'           => array(),
					'failed'            => array(),
					'skipped_no_update' => $skipped,
					'message'           => 'No updates needed for the requested plugins.',
				) );
			}
		}

		// Snapshot pre-upgrade versions for verify.
		$pre_versions = array();
		foreach ( $plugin_files as $file ) {
			$data                  = get_plugin_data( WP_PLUGIN_DIR . '/' . $file, false, false );
			$pre_versions[ $file ] = isset( $data['Version'] ) ? (string) $data['Version'] : '';
		}

		$upgrader = new Plugin_Upgrader( new WP_Ajax_Upgrader_Skin() );
		$results  = $upgrader->bulk_upgrade( $plugin_files );

		// `bulk_upgrade` returns `false` only when the upgrader couldn't
		// even start (filesystem init failed mid-flight, no plugins
		// supplied). Top-level `WP_Error` is the same — surface and bail.
		if ( false === $results ) {
			return Response::error( 'Plugin update did not start (Plugin_Upgrader::bulk_upgrade returned false).' );
		}
		if ( $results instanceof WP_Error ) {
			return Response::error( sprintf( 'Update failed: %s', $results->get_error_message() ) );
		}
		if ( ! is_array( $results ) ) {
			return Response::error( 'Unexpected response from Plugin_Upgrader::bulk_upgrade.' );
		}

		wp_clean_plugins_cache();

		// Per-slug verify: compare post-upgrade Version against pre.
		// `bulk_upgrade` populates `$results[$file]` with either `true`,
		// `null` (no-op), a `WP_Error`, or the upgrader-internal array.
		// We trust the read-back, not the result-slot type — same lesson
		// as theme-activate (filter silently no-op'd, handler reported
		// success). Authoritative signal: did Version advance?
		$updated = array();
		$failed  = array();

		foreach ( $plugin_files as $file ) {
			$slot   = $results[ $file ] ?? null;
			$pre_v  = $pre_versions[ $file ];
			$post   = get_plugin_data( WP_PLUGIN_DIR . '/' . $file, false, false );
			$post_v = isset( $post['Version'] ) ? (string) $post['Version'] : '';

			if ( $slot instanceof WP_Error ) {
				$failed[] = array(
					'plugin_file' => $file,
					'from'        => $pre_v,
					'reason'      => $slot->get_error_message(),
				);
				continue;
			}
			if ( '' === $post_v ) {
				$failed[] = array(
					'plugin_file' => $file,
					'from'        => $pre_v,
					'reason'      => 'plugin file unreadable after upgrade — possibly half-installed',
				);
				continue;
			}
			if ( $post_v === $pre_v ) {
				// Version didn't advance — silent failure or no-op (the
				// upgrader's per-slot null path).
				$failed[] = array(
					'plugin_file' => $file,
					'from'        => $pre_v,
					'to'          => $post_v,
					'reason'      => 'version did not advance — upgrade silently rejected',
				);
				continue;
			}
			$updated[] = array(
				'plugin_file' => $file,
				'from'        => $pre_v,
				'to'          => $post_v,
			);
		}

		// All-fail → error envelope so the brain can't mark dependent
		// todos done. Same truth-telling rule as PluginInstall +
		// post-delete + core__bulk_run_wp_cli.
		if ( empty( $updated ) && ! empty( $failed ) ) {
			$summaries = array_map(
				static fn ( $f ) => $f['plugin_file'] . ' (' . $f['reason'] . ')',
				$failed
			);
			return Response::error( sprintf(
				'No plugins updated. Failures: %s.',
				implode( '; ', $summaries )
			) );
		}

		return Response::success( array(
			'updated'           => $updated,
			'failed'            => $failed,
			'skipped_no_update' => $skipped,
			'message'           => sprintf(
				'%d plugin(s) updated%s%s.',
				count( $updated ),
				! empty( $failed ) ? ', ' . count( $failed ) . ' failed' : '',
				! empty( $skipped ) ? ', ' . count( $skipped ) . ' skipped (already current or not installed)' : ''
			),
		) );
	}

	public function get_output_schema() {
		return array(
			'type'                 => 'object',
			'required'             => array( 'success' ),
			'additionalProperties' => true,
			'properties'           => array(
				'success' => array( 'type' => 'boolean' ),
				'message' => array( 'type' => 'string' ),
				'data'    => array(
					'type'       => 'object',
					'properties' => array(
						'updated'           => array(
							'type'  => 'array',
							'items' => array(
								'type'       => 'object',
								'properties' => array(
									'plugin_file' => array( 'type' => 'string' ),
									'from'        => array( 'type' => 'string' ),
									'to'          => array( 'type' => 'string' ),
								),
							),
						),
						'failed'            => array(
							'type'  => 'array',
							'items' => array(
								'type'       => 'object',
								'properties' => array(
									'plugin_file' => array( 'type' => 'string' ),
									'from'        => array( 'type' => 'string' ),
									'to'          => array( 'type' => 'string' ),
									'reason'      => array( 'type' => 'string' ),
								),
							),
						),
						'skipped_no_update' => array(
							'type'  => 'array',
							'items' => array(
								'type'       => 'object',
								'properties' => array(
									'slug'        => array( 'type' => 'string' ),
									'plugin_file' => array( 'type' => 'string' ),
									'reason'      => array( 'type' => 'string' ),
								),
							),
						),
					),
				),
			),
		);
	}
}
