<?php
/**
 * WordPress core tools — posts, options, users.
 * No "Pro plan" gates. Writes are real writes; snapshots are taken first.
 */

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

class PCD_PC_Tools_WP {

	public static function register(): void {

		// On the WordPress.org "Lite" build, several generic escape-hatch tools
		// are withheld. They are fine for the relay/direct build (bound to the
		// linked administrator) but WordPress.org's guidelines disallow them:
		//   - arbitrary wp_options read/write (can expose API keys / write any option)
		//   - arbitrary post-meta writes (incl. protected _-prefixed keys)
		//   - self-update from our own API (WordPress.org serves updates)
		//   - an arbitrary outbound HTTP fetch
		// The Divi build/migrate surface — the reason the plugin exists — stays.
		$lite = function_exists( 'pcd_pc_is_wporg_build' ) && pcd_pc_is_wporg_build();

		// ---- POSTS ----

		PCD_PC_Tools::register(
			'wp_post_list',
			'List posts/pages/CPTs. Filterable by post_type, status, search.',
			[
				'type' => 'object',
				'properties' => [
					'post_type'   => [ 'type' => 'string', 'default' => 'post' ],
					'post_status' => [ 'type' => 'string', 'default' => 'any' ],
					'search'      => [ 'type' => 'string' ],
					'per_page'    => [ 'type' => 'integer', 'default' => 20 ],
				],
			],
			[ __CLASS__, 'post_list' ],
			[ 'cap' => 'edit_posts', 'category' => 'wp_posts' ]
		);

		PCD_PC_Tools::register(
			'wp_post_get',
			'Get a single post by ID, including full content and meta.',
			[
				'type' => 'object',
				'required' => [ 'id' ],
				'properties' => [
					'id' => [ 'type' => 'integer' ],
					'include_meta' => [ 'type' => 'boolean', 'default' => false ],
				],
			],
			[ __CLASS__, 'post_get' ],
			[ 'cap' => 'edit_posts', 'category' => 'wp_posts' ]
		);

		PCD_PC_Tools::register(
			'wp_post_create',
			'Create a new post/page/CPT.',
			[
				'type' => 'object',
				'required' => [ 'post_title' ],
				'properties' => [
					'post_title'   => [ 'type' => 'string' ],
					'post_content' => [ 'type' => 'string' ],
					'post_type'    => [ 'type' => 'string', 'default' => 'post' ],
					'post_status'  => [ 'type' => 'string', 'default' => 'draft' ],
					'post_excerpt' => [ 'type' => 'string' ],
					'post_name'    => [ 'type' => 'string' ],
					'meta'         => [ 'type' => 'object' ],
				],
			],
			[ __CLASS__, 'post_create' ],
			[ 'cap' => 'edit_posts', 'category' => 'wp_posts' ]
		);

		PCD_PC_Tools::register(
			'wp_post_update',
			'Update fields on an existing post. Snapshots the prior state for undo.',
			[
				'type' => 'object',
				'required' => [ 'id' ],
				'properties' => [
					'id'           => [ 'type' => 'integer' ],
					'post_title'   => [ 'type' => 'string' ],
					'post_content' => [ 'type' => 'string' ],
					'post_status'  => [ 'type' => 'string' ],
					'post_excerpt' => [ 'type' => 'string' ],
					'post_name'    => [ 'type' => 'string' ],
					'meta'         => [ 'type' => 'object' ],
				],
			],
			[ __CLASS__, 'post_update' ],
			[ 'cap' => 'edit_posts', 'category' => 'wp_posts' ]
		);

		PCD_PC_Tools::register(
			'wp_post_delete',
			'Trash or permanently delete a post.',
			[
				'type' => 'object',
				'required' => [ 'id' ],
				'properties' => [
					'id'    => [ 'type' => 'integer' ],
					'force' => [ 'type' => 'boolean', 'default' => false ],
					'confirm' => [ 'type' => 'boolean' ],
				],
			],
			[ __CLASS__, 'post_delete' ],
			[ 'cap' => 'delete_posts', 'category' => 'wp_posts', 'destructive' => true ]
		);

		// ---- OPTIONS + META (withheld on the WordPress.org Lite build) ----

		if ( ! $lite ) {

		PCD_PC_Tools::register(
			'wp_option_get',
			'Read a wp_options value.',
			[
				'type' => 'object',
				'required' => [ 'key' ],
				'properties' => [ 'key' => [ 'type' => 'string' ] ],
			],
			[ __CLASS__, 'option_get' ],
			[ 'cap' => 'manage_options', 'category' => 'wp_options' ]
		);

		PCD_PC_Tools::register(
			'wp_option_update',
			'Write a wp_options value. Snapshots the prior value.',
			[
				'type' => 'object',
				'required' => [ 'key', 'value' ],
				'properties' => [
					'key'   => [ 'type' => 'string' ],
					'value' => [
						'description' => 'Option value. Can be a string, number, boolean, array, object, or null.',
					],
				],
			],
			[ __CLASS__, 'option_update' ],
			[ 'cap' => 'manage_options', 'category' => 'wp_options' ]
		);

		// ---- META ----

		PCD_PC_Tools::register(
			'wp_post_meta_update',
			'Set a post meta value (works on protected _-prefixed keys too). Snapshots prior value.',
			[
				'type' => 'object',
				'required' => [ 'post_id', 'key', 'value' ],
				'properties' => [
					'post_id' => [ 'type' => 'integer' ],
					'key'     => [ 'type' => 'string' ],
					'value'   => [
						'description' => 'Meta value. Can be a string, number, boolean, array, object, or null.',
					],
				],
			],
			[ __CLASS__, 'meta_update' ],
			[ 'cap' => 'edit_posts', 'category' => 'wp_meta' ]
		);

		} // end ! $lite (options + meta)

		// ---- DIAGNOSTICS ----

		PCD_PC_Tools::register(
			'wp_site_info',
			'Site info: WP version, PHP version, active theme, plugin list.',
			[ 'type' => 'object', 'properties' => new stdClass() ],
			[ __CLASS__, 'site_info' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'diagnostics' ]
		);

		// ---- EXTERNAL FETCH (withheld on the WordPress.org Lite build) ----

		if ( ! $lite ) {

		PCD_PC_Tools::register(
			'pcd_pc_fetch_url',
			'Fetch any external URL (your app\'s API, a JSON endpoint, a public page) and return the response. Used to pull data from your own apps/services so Claude can build Bricks pages from that data. SSRF-guarded — refuses private/reserved IP ranges.',
			[
				'type' => 'object',
				'required' => [ 'url' ],
				'properties' => [
					'url'     => [ 'type' => 'string', 'description' => 'Public HTTPS URL.' ],
					'method'  => [ 'type' => 'string', 'enum' => [ 'GET', 'POST' ], 'default' => 'GET' ],
					'headers' => [ 'type' => 'object', 'description' => 'Optional headers, e.g. { "Authorization": "Bearer ..." }.' ],
					'body'    => [ 'type' => 'string', 'description' => 'Optional request body (for POST).' ],
					'timeout' => [ 'type' => 'integer', 'default' => 15 ],
				],
			],
			[ __CLASS__, 'fetch_url' ],
			[ 'cap' => 'manage_options', 'category' => 'integration' ]
		);

		} // end ! $lite (external fetch)

		// ---- RENDERED HTML ----

		PCD_PC_Tools::register(
			'wp_page_rendered_html',
			'Fetch the fully-rendered HTML of a page on this site. Pass `path` (e.g. "/", "/about/") or `post_id` (uses preview link for non-public posts). Returns the HTML the browser would actually see.',
			[
				'type' => 'object',
				'properties' => [
					'path'    => [ 'type' => 'string' ],
					'post_id' => [ 'type' => 'integer' ],
				],
			],
			[ __CLASS__, 'page_rendered_html' ],
			[ 'cap' => 'edit_posts', 'category' => 'rendering' ]
		);

		// ---- SELF-UPDATE (withheld on the WordPress.org Lite build; WordPress.org
		// serves updates, and the updater class is not shipped in that build) ----

		if ( ! $lite ) {

		PCD_PC_Tools::register(
			'pcd_pc_check_update',
			'Check the PC Designs backend for a newer version of PressConduit. Returns current and latest versions plus the zip URL.',
			[ 'type' => 'object', 'properties' => new stdClass() ],
			[ __CLASS__, 'check_update' ],
			[ 'cap' => 'update_plugins', 'category' => 'self_update' ]
		);

		PCD_PC_Tools::register(
			'pcd_pc_self_update',
			'Update PressConduit to the latest version published by PC Designs. Two-phase: first call returns version info + warning; pass confirm=true to actually upgrade.',
			[
				'type' => 'object',
				'properties' => [
					'confirm' => [ 'type' => 'boolean', 'default' => false ],
				],
			],
			[ __CLASS__, 'self_update' ],
			[ 'cap' => 'update_plugins', 'category' => 'self_update' ]
		);

		} // end ! $lite (self-update)

		// ---- SNAPSHOTS ----

		PCD_PC_Tools::register(
			'pcd_snapshot_list',
			'List recent snapshots so you can undo prior changes.',
			[
				'type' => 'object',
				'properties' => [ 'limit' => [ 'type' => 'integer', 'default' => 50 ] ],
			],
			function ( $args ) {
				return PCD_PC_Snapshot::recent( (int) ( $args['limit'] ?? 50 ) );
			},
			[ 'cap' => 'manage_options', 'category' => 'safety' ]
		);

		PCD_PC_Tools::register(
			'pcd_snapshot_restore',
			'Restore a prior snapshot by ID. Reverses a previous tool call.',
			[
				'type' => 'object',
				'required' => [ 'snapshot_id' ],
				'properties' => [
					'snapshot_id' => [ 'type' => 'integer' ],
					'confirm'     => [ 'type' => 'boolean' ],
				],
			],
			function ( $args ) {
				return PCD_PC_Snapshot::restore( (int) $args['snapshot_id'] );
			},
			[ 'cap' => 'manage_options', 'category' => 'safety', 'destructive' => true ]
		);
	}

	// ------------------------------------------------------------------
	// Handlers
	// ------------------------------------------------------------------

	public static function post_list( array $args ): array {
		$query = new WP_Query( [
			'post_type'      => $args['post_type'] ?? 'post',
			'post_status'    => $args['post_status'] ?? 'any',
			's'              => $args['search'] ?? '',
			'posts_per_page' => min( 100, (int) ( $args['per_page'] ?? 20 ) ),
			'no_found_rows'  => true,
			'fields'         => 'ids',
		] );

		$out = [];
		foreach ( $query->posts as $id ) {
			$p = get_post( $id );
			$out[] = [
				'id'      => $p->ID,
				'title'   => $p->post_title,
				'status'  => $p->post_status,
				'type'    => $p->post_type,
				'slug'    => $p->post_name,
				'date'    => $p->post_date,
				'modified' => $p->post_modified,
			];
		}
		return [ 'count' => count( $out ), 'posts' => $out ];
	}

	public static function post_get( array $args ) {
		$post = get_post( (int) $args['id'] );
		if ( ! $post ) {
			return new WP_Error( 'not_found', "Post not found: {$args['id']}" );
		}

		$data = [
			'id'           => $post->ID,
			'title'        => $post->post_title,
			'content'      => $post->post_content,
			'excerpt'      => $post->post_excerpt,
			'status'       => $post->post_status,
			'type'         => $post->post_type,
			'slug'         => $post->post_name,
			'permalink'    => get_permalink( $post ),
			'date'         => $post->post_date,
			'modified'     => $post->post_modified,
			'author'       => (int) $post->post_author,
		];

		if ( ! empty( $args['include_meta'] ) ) {
			$data['meta'] = get_post_meta( $post->ID );
		}

		return $data;
	}

	public static function post_create( array $args ) {
		// wp_insert_post unslashes input internally — slash text fields so
		// backslashes (e.g. < JSON escapes in Bricks builder data) survive.
		$post = [
			'post_title'   => wp_slash( (string) ( $args['post_title'] ?? '' ) ),
			'post_content' => wp_slash( (string) ( $args['post_content'] ?? '' ) ),
			'post_type'    => $args['post_type'] ?? 'post',
			'post_status'  => $args['post_status'] ?? 'draft',
			'post_excerpt' => wp_slash( (string) ( $args['post_excerpt'] ?? '' ) ),
			'post_author'  => get_current_user_id(),
		];
		if ( ! empty( $args['post_name'] ) ) {
			$post['post_name'] = $args['post_name'];
		}

		$id = wp_insert_post( $post, true );
		if ( is_wp_error( $id ) ) {
			return $id;
		}

		if ( ! empty( $args['meta'] ) && is_array( $args['meta'] ) ) {
			foreach ( $args['meta'] as $k => $v ) {
				update_post_meta( $id, $k, $v );
			}
		}

		return [ 'id' => $id, 'permalink' => get_permalink( $id ), 'edit_url' => admin_url( "post.php?post={$id}&action=edit" ) ];
	}

	public static function post_update( array $args ) {
		$id   = (int) $args['id'];
		$post = get_post( $id );
		if ( ! $post ) {
			return new WP_Error( 'not_found', "Post not found: {$id}" );
		}

		// Snapshot prior state.
		$snapshot_id = PCD_PC_Snapshot::capture( 'post', (string) $id, [
			'ID'           => $id,
			'post_title'   => $post->post_title,
			'post_content' => $post->post_content,
			'post_excerpt' => $post->post_excerpt,
			'post_status'  => $post->post_status,
			'post_name'    => $post->post_name,
		], "Post #{$id} before update" );

		// wp_update_post unslashes input — slash text fields so backslashes
		// (e.g. < JSON escapes in Bricks builder data) survive.
		$slashed_fields = [ 'post_title', 'post_content', 'post_excerpt' ];
		$update = [ 'ID' => $id ];
		foreach ( [ 'post_title', 'post_content', 'post_excerpt', 'post_status', 'post_name' ] as $f ) {
			if ( isset( $args[ $f ] ) ) {
				$update[ $f ] = in_array( $f, $slashed_fields, true )
					? wp_slash( (string) $args[ $f ] )
					: $args[ $f ];
			}
		}

		$result = wp_update_post( $update, true );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		if ( ! empty( $args['meta'] ) && is_array( $args['meta'] ) ) {
			foreach ( $args['meta'] as $k => $v ) {
				update_post_meta( $id, $k, $v );
			}
		}

		return [ 'id' => $id, 'snapshot_id' => $snapshot_id, 'permalink' => get_permalink( $id ) ];
	}

	public static function post_delete( array $args ) {
		$id   = (int) $args['id'];
		$post = get_post( $id );
		if ( ! $post ) {
			return new WP_Error( 'not_found', "Post not found: {$id}" );
		}

		$force = ! empty( $args['force'] );
		$result = $force ? wp_delete_post( $id, true ) : wp_trash_post( $id );

		return $result ? [ 'id' => $id, 'action' => $force ? 'deleted' : 'trashed' ] : new WP_Error( 'delete_failed', 'Delete failed.' );
	}

	/**
	 * Block reads/writes of options whose names suggest they hold credentials
	 * or secrets (API keys, tokens, passwords, salts, this plugin's own auth
	 * options). The option tools that call this are not even registered on the
	 * WordPress.org Lite build; this denylist is defense-in-depth for the
	 * relay build so a connected AI can never surface a secret option value.
	 */
	private static function option_key_is_protected( string $key ): bool {
		$key_l = strtolower( $key );
		foreach ( [ 'secret', 'token', 'password', 'passwd', 'private', 'salt', 'nonce', 'api_key', 'apikey', 'auth', 'credential' ] as $needle ) {
			if ( false !== strpos( $key_l, $needle ) ) {
				return true;
			}
		}
		return in_array( $key_l, [ 'auth_key', 'auth_salt', 'secret_key', 'pcd_pc_token', 'pcd_pc_user' ], true );
	}

	public static function option_get( array $args ): array {
		$key = (string) $args['key'];
		if ( self::option_key_is_protected( $key ) ) {
			return [ 'key' => $key, 'value' => null, 'exists' => false, 'blocked' => 'Reading this option is blocked because its name suggests it holds a secret or credential.' ];
		}
		$value = get_option( $key, null );
		return [ 'key' => $key, 'value' => $value, 'exists' => null !== $value ];
	}

	public static function option_update( array $args ): array {
		$key   = (string) $args['key'];
		$value = $args['value'];

		if ( self::option_key_is_protected( $key ) ) {
			return [ 'key' => $key, 'updated' => false, 'blocked' => 'Writing this option is blocked because its name suggests it holds a secret or credential.' ];
		}

		$prior = get_option( $key, null );
		$snapshot_id = PCD_PC_Snapshot::capture( 'option', $key, $prior, "Option {$key} before update" );

		// update_option unslashes — slash string values so backslashes survive.
		update_option( $key, is_string( $value ) ? wp_slash( $value ) : $value );

		return [ 'key' => $key, 'snapshot_id' => $snapshot_id, 'updated' => true ];
	}

	public static function meta_update( array $args ): array {
		$post_id = (int) $args['post_id'];
		$key     = $args['key'];
		$value   = $args['value'];

		$prior = get_post_meta( $post_id, $key, true );
		$snapshot_id = PCD_PC_Snapshot::capture( 'meta', "{$post_id}:{$key}", [
			'post_id' => $post_id,
			'key'     => $key,
			'value'   => $prior,
		], "Meta {$key} on #{$post_id}" );

		// update_post_meta unslashes — slash so backslashes in string values survive.
		update_post_meta( $post_id, $key, is_string( $value ) ? wp_slash( $value ) : $value );
		return [ 'post_id' => $post_id, 'key' => $key, 'snapshot_id' => $snapshot_id ];
	}

	public static function site_info( array $args ): array {
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		$theme = wp_get_theme();
		$builder_version = defined( 'BRICKS_VERSION' ) ? BRICKS_VERSION
			: ( defined( 'ET_BUILDER_VERSION' ) ? ET_BUILDER_VERSION
			: ( defined( 'ET_CORE_VERSION' ) ? ET_CORE_VERSION : null ) );
		return [
			'site_name'    => get_bloginfo( 'name' ),
			'site_url'     => site_url(),
			'wp_version'   => get_bloginfo( 'version' ),
			'php_version'  => phpversion(),
			// Which page builder this site runs, so the AI knows up front whether
			// to reach for the Divi or Bricks tools/docs. 'divi' | 'bricks' | 'none'.
			'builder'         => PCD_PC_Builder::primary(),
			'builder_version' => $builder_version,
			'divi_generation' => PCD_PC_Builder::is_divi() ? PCD_PC_Builder::divi_generation() : null,
			'active_theme' => [
				'name'       => $theme->get( 'Name' ),
				'stylesheet' => get_stylesheet(),
				'template'   => get_template(),
				'version'    => $theme->get( 'Version' ),
			],
			'plugins' => array_map( function ( $f ) {
				return [ 'file' => $f, 'active' => is_plugin_active( $f ) ];
			}, array_keys( get_plugins() ) ),
		];
	}

	// ------------------------------------------------------------------
	// External fetch (for connecting Claude to your own apps/APIs)
	// ------------------------------------------------------------------

	public static function fetch_url( array $args ) {
		$url = esc_url_raw( (string) ( $args['url'] ?? '' ) );
		if ( ! $url ) {
			return new WP_Error( 'no_url', 'url is required.' );
		}
		if ( ! preg_match( '#^https?://#i', $url ) ) {
			return new WP_Error( 'invalid_url', 'URL must be http(s).' );
		}

		// SSRF guard.
		$host = wp_parse_url( $url, PHP_URL_HOST );
		if ( ! $host ) {
			return new WP_Error( 'invalid_url', 'Could not parse host.' );
		}
		$ip = gethostbyname( $host );
		if ( $ip && $ip !== $host && ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
			return new WP_Error( 'blocked_url', "Refusing to fetch {$host} ({$ip}) — private or reserved IP range." );
		}

		$method   = strtoupper( (string) ( $args['method'] ?? 'GET' ) );
		$headers  = is_array( $args['headers'] ?? null ) ? $args['headers'] : [];
		$body     = (string) ( $args['body'] ?? '' );
		$timeout  = min( 60, max( 1, (int) ( $args['timeout'] ?? 15 ) ) );

		$request_args = [
			'method'    => $method,
			'headers'   => $headers,
			'timeout'   => $timeout,
			'sslverify' => true,
		];
		if ( 'POST' === $method && '' !== $body ) {
			$request_args['body'] = $body;
		}

		$response = wp_remote_request( $url, $request_args );
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$status   = (int) wp_remote_retrieve_response_code( $response );
		$body_raw = wp_remote_retrieve_body( $response );
		$ctype    = wp_remote_retrieve_header( $response, 'content-type' );

		// Truncate big payloads — 200KB cap protects Claude's context.
		$max_bytes = 200 * 1024;
		$truncated = false;
		if ( strlen( $body_raw ) > $max_bytes ) {
			$body_raw  = substr( $body_raw, 0, $max_bytes );
			$truncated = true;
		}

		// If the response looks like JSON, decode so Claude doesn't have to.
		$parsed_json = null;
		if ( $ctype && false !== stripos( $ctype, 'application/json' ) ) {
			$parsed_json = json_decode( $body_raw, true );
		}

		return [
			'url'          => $url,
			'status'       => $status,
			'content_type' => $ctype,
			'truncated'    => $truncated,
			'body'         => $body_raw,
			'json'         => $parsed_json, // null if not JSON or parse failed
		];
	}

	// ------------------------------------------------------------------
	// Rendered HTML
	// ------------------------------------------------------------------

	public static function page_rendered_html( array $args ) {
		$url = '';
		if ( ! empty( $args['post_id'] ) ) {
			$pid = (int) $args['post_id'];
			$post = get_post( $pid );
			if ( ! $post ) {
				return new WP_Error( 'no_post', "Post #{$pid} not found." );
			}
			// Public → permalink, non-public → preview link.
			$url = in_array( $post->post_status, [ 'publish', 'inherit' ], true )
				? get_permalink( $pid )
				: get_preview_post_link( $pid );
		} elseif ( ! empty( $args['path'] ) ) {
			$url = home_url( '/' . ltrim( (string) $args['path'], '/' ) );
		} else {
			$url = home_url( '/' );
		}

		if ( ! $url ) {
			return new WP_Error( 'no_url', 'Could not resolve a URL to fetch.' );
		}

		// Forward the current logged-in user via cookie so non-public pages render.
		$cookies = [];
		foreach ( $_COOKIE as $name => $value ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			if ( 0 === strpos( $name, 'wordpress_' ) ) {
				$cookies[] = new WP_Http_Cookie( [ 'name' => $name, 'value' => $value ] );
			}
		}

		$response = wp_remote_get( $url, [
			'timeout'   => 15,
			'sslverify' => true,
			'cookies'   => $cookies,
			'redirection' => 3,
		] );
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$body   = wp_remote_retrieve_body( $response );
		$status = (int) wp_remote_retrieve_response_code( $response );

		return [
			'url'    => $url,
			'status' => $status,
			'bytes'  => strlen( $body ),
			'html'   => $body,
		];
	}

	// ------------------------------------------------------------------
	// Self-update
	// ------------------------------------------------------------------

	private static function fetch_update_manifest() {
		$backend = PCD_PC_Pair::backend_url();
		if ( ! $backend ) {
			return new WP_Error( 'no_backend', 'Backend URL is not configured.' );
		}
		// Identify the product, or the backend defaults to the frozen
		// divi-conduit manifest and this site never sees an update.
		$response = wp_remote_get( $backend . '/plugin/latest?product=pressconduit', [
			'timeout' => 10,
			'headers' => [ 'X-Conduit-Product' => 'pressconduit' ],
		] );
		if ( is_wp_error( $response ) ) return $response;

		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( $code < 200 || $code >= 300 ) {
			return new WP_Error( 'backend_error', "Backend returned HTTP {$code}." );
		}
		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( ! is_array( $body ) || empty( $body['version'] ) || empty( $body['zip_url'] ) ) {
			return new WP_Error( 'bad_manifest', 'Backend manifest is missing version or zip_url.' );
		}
		return $body;
	}

	public static function check_update( array $args ) {
		$manifest = self::fetch_update_manifest();
		if ( is_wp_error( $manifest ) ) return $manifest;

		$current = defined( 'PCD_PC_VERSION' ) ? PCD_PC_VERSION : '0.0.0';
		$cmp     = version_compare( $current, $manifest['version'] );

		return [
			'current_version' => $current,
			'latest_version'  => $manifest['version'],
			'zip_url'         => $manifest['zip_url'],
			'update_available'=> $cmp < 0,
			'on_latest'       => $cmp >= 0,
		];
	}

	public static function self_update( array $args ) {
		// Capability + file-mods guard.
		if ( defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS ) {
			return new WP_Error( 'file_mods_disabled', 'File modifications are disabled on this site (DISALLOW_FILE_MODS).' );
		}

		$manifest = self::fetch_update_manifest();
		if ( is_wp_error( $manifest ) ) return $manifest;

		$current = defined( 'PCD_PC_VERSION' ) ? PCD_PC_VERSION : '0.0.0';
		$cmp     = version_compare( $current, $manifest['version'] );

		if ( $cmp >= 0 ) {
			return [
				'updated'         => false,
				'current_version' => $current,
				'latest_version'  => $manifest['version'],
				'message'         => "Already on the latest version ({$current}).",
			];
		}

		// Two-phase: first call returns the preview.
		if ( empty( $args['confirm'] ) ) {
			return [
				'requires_confirmation' => true,
				'current_version' => $current,
				'latest_version'  => $manifest['version'],
				'zip_url'         => $manifest['zip_url'],
				'message'         => "Ready to update PressConduit from {$current} to {$manifest['version']}. Re-call with confirm:true to proceed.",
			];
		}

		// Phase 2: actual install.
		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';

		WP_Filesystem();

		$skin     = new Automatic_Upgrader_Skin();
		$upgrader = new Plugin_Upgrader( $skin );

		// overwrite_package=true tells Plugin_Upgrader to replace existing files
		// instead of erroring on "destination folder already exists" — same
		// mechanism wp-admin's "Replace current with uploaded" uses.
		$result = $upgrader->install( $manifest['zip_url'], [ 'overwrite_package' => true ] );

		if ( is_wp_error( $result ) ) return $result;
		if ( ! $result ) {
			return new WP_Error( 'install_failed', 'Install failed: ' . implode( '; ', $skin->get_upgrade_messages() ) );
		}

		// Ensure the plugin stays activated. Plugin_Upgrader::install can leave it deactivated.
		$plugin_file = 'pressconduit/pressconduit.php';
		if ( ! is_plugin_active( $plugin_file ) ) {
			activate_plugin( $plugin_file );
		}

		return [
			'updated'         => true,
			'previous_version'=> $current,
			'new_version'     => $manifest['version'],
			'message'         => "Updated PressConduit from {$current} to {$manifest['version']}.",
		];
	}
}
