<?php
/**
 * Authenticated CRUD + preview endpoints for V2 guided schema instances.
 *
 * Editor surface: action-specific admin-ajax handlers (nonce + edit_post).
 * Web/connector surface: seovaultai/v1 REST routes (site-key + posts caps).
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

use SEOvaultSEO\Api\RouteCapabilities;
use SEOvaultSEO\Api\Router;
use SEOvaultSEO\Api\SchemaInstanceConnectorContract;
use SEOvaultSEO\Schema\Readiness\SchemaReadinessEvaluator;
use SEOvaultSEO\Utils\Logger;
use SEOvaultSEO\Utils\Sanitizer;

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

/**
 * Instance list/create/update/delete/preview API.
 */
class SchemaInstanceEndpoints {

	/**
	 * AJAX / REST nonce action for editor calls.
	 */
	const NONCE_ACTION = 'seovaultseo_schema_instances';

	/**
	 * Soft max JSON body bytes (filterable).
	 */
	const DEFAULT_MAX_REQUEST_BYTES = 131072;

	/**
	 * Hard max JSON body bytes.
	 */
	const HARD_MAX_REQUEST_BYTES = 262144;

	/**
	 * Idempotency transient TTL (seconds).
	 */
	const IDEMPOTENCY_TTL = DAY_IN_SECONDS;

	/**
	 * Whether init has run.
	 *
	 * @var bool
	 */
	private static $initialized = false;

	/**
	 * Register AJAX + REST hooks.
	 *
	 * @return void
	 */
	public static function init() {
		if ( self::$initialized ) {
			return;
		}
		self::$initialized = true;

		add_action( 'wp_ajax_seovaultseo_schema_instances_list', array( __CLASS__, 'ajax_list' ) );
		add_action( 'wp_ajax_seovaultseo_schema_instances_create', array( __CLASS__, 'ajax_create' ) );
		add_action( 'wp_ajax_seovaultseo_schema_instances_update', array( __CLASS__, 'ajax_update' ) );
		add_action( 'wp_ajax_seovaultseo_schema_instances_delete', array( __CLASS__, 'ajax_delete' ) );
		add_action( 'wp_ajax_seovaultseo_schema_instances_preview', array( __CLASS__, 'ajax_preview' ) );

		add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
	}

	/**
	 * Register connector REST routes under seovaultai/v1.
	 *
	 * @return void
	 */
	public static function register_rest_routes() {
		$ns      = Router::NAMESPACE;
		$post_id = '(?P<id>\d+)';
		$inst_id = '(?P<instance_id>[a-fA-F0-9\-]{8,64}|sv2-post-[a-fA-F0-9]{16,64})';

		register_rest_route(
			$ns,
			'/posts/' . $post_id . '/schema-instances',
			array(
				array(
					'methods'             => 'GET',
					'callback'            => array( __CLASS__, 'rest_list' ),
					'permission_callback' => Router::require_caps( RouteCapabilities::CAP_POSTS_READ ),
				),
				array(
					'methods'             => 'POST',
					'callback'            => array( __CLASS__, 'rest_create' ),
					'permission_callback' => Router::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
				),
			)
		);

		register_rest_route(
			$ns,
			'/posts/' . $post_id . '/schema-instances/preview',
			array(
				'methods'             => 'POST',
				'callback'            => array( __CLASS__, 'rest_preview' ),
				'permission_callback' => Router::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/posts/' . $post_id . '/schema-instances/' . $inst_id,
			array(
				'methods'             => 'POST',
				'callback'            => array( __CLASS__, 'rest_update' ),
				'permission_callback' => Router::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/posts/' . $post_id . '/schema-instances/' . $inst_id . '/delete',
			array(
				'methods'             => 'POST',
				'callback'            => array( __CLASS__, 'rest_delete' ),
				'permission_callback' => Router::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/posts/' . $post_id . '/schema-instance-contract',
			array(
				'methods'             => 'GET',
				'callback'            => array( __CLASS__, 'rest_contract' ),
				'permission_callback' => Router::require_caps( RouteCapabilities::CAP_POSTS_READ ),
			)
		);

		register_rest_route(
			$ns,
			'/posts/' . $post_id . '/schema-instance-contract/(?P<type>[A-Za-z][A-Za-z0-9]*)',
			array(
				'methods'             => 'GET',
				'callback'            => array( __CLASS__, 'rest_contract' ),
				'permission_callback' => Router::require_caps( RouteCapabilities::CAP_POSTS_READ ),
			)
		);
	}

	// -----------------------------------------------------------------------
	// AJAX handlers
	// -----------------------------------------------------------------------

	/**
	 * AJAX: list instances.
	 *
	 * @return void
	 */
	public static function ajax_list() {
		self::ajax_dispatch( 'list' );
	}

	/**
	 * AJAX: create draft instance.
	 *
	 * @return void
	 */
	public static function ajax_create() {
		self::ajax_dispatch( 'create' );
	}

	/**
	 * AJAX: update instance.
	 *
	 * @return void
	 */
	public static function ajax_update() {
		self::ajax_dispatch( 'update' );
	}

	/**
	 * AJAX: delete instance.
	 *
	 * @return void
	 */
	public static function ajax_delete() {
		self::ajax_dispatch( 'delete' );
	}

	/**
	 * AJAX: preview readiness without writing.
	 *
	 * @return void
	 */
	public static function ajax_preview() {
		self::ajax_dispatch( 'preview' );
	}

	/**
	 * Shared AJAX dispatcher.
	 *
	 * @param string $op Operation.
	 * @return void
	 */
	private static function ajax_dispatch( $op ) {
		if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
			wp_send_json_error(
				array(
					'code'    => 'invalid_nonce',
					'message' => __( 'Security check failed. Reload the editor and try again.', 'seovault-ai' ),
				),
				403
			);
		}

		$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
		$payload = self::extract_ajax_payload();
		$result  = self::handle( $op, $post_id, $payload, array( 'channel' => 'ajax' ) );

		if ( empty( $result['ok'] ) ) {
			wp_send_json_error(
				array(
					'code'     => $result['code'],
					'message'  => $result['message'],
					'errors'   => isset( $result['errors'] ) ? $result['errors'] : array(),
					'conflict' => isset( $result['conflict'] ) ? $result['conflict'] : null,
				),
				isset( $result['status'] ) ? (int) $result['status'] : 400
			);
		}

		wp_send_json_success( $result['data'] );
	}

	/**
	 * Decode AJAX body fields / JSON blob.
	 *
	 * @return array<string,mixed>
	 */
	private static function extract_ajax_payload() {
		$payload = array();

		/*
		 * Nonce verified in ajax_dispatch() before this helper runs.
		 * phpcs:disable WordPress.Security.NonceVerification.Missing
		 */
		$raw_json = null;
		if ( isset( $_POST['payload'] ) ) {
			$raw_json = is_array( $_POST['payload'] )
				? map_deep( wp_unslash( $_POST['payload'] ), 'sanitize_text_field' )
				: wp_strip_all_tags( wp_unslash( (string) $_POST['payload'] ), true );
		}
		if ( is_string( $raw_json ) && '' !== $raw_json ) {
			$decoded = json_decode( $raw_json, true );
			if ( is_array( $decoded ) ) {
				$payload = Sanitizer::sanitize_schema_data( $decoded );
			} else {
				$payload['_malformed_json'] = true;
			}
			$payload['_payload_bytes'] = strlen( $raw_json );
		} elseif ( is_array( $raw_json ) ) {
			$payload                   = $raw_json;
			$payload['_payload_bytes'] = strlen( (string) wp_json_encode( $raw_json ) );
		}

		foreach ( array( 'type', 'instance_id', 'idempotency_key', 'content_hash', 'source' ) as $key ) {
			if ( isset( $_POST[ $key ] ) && ! isset( $payload[ $key ] ) ) {
				$payload[ $key ] = sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
			}
		}

		if ( isset( $_POST['fields'] ) && ! isset( $payload['fields'] ) ) {
			$fields_raw = is_array( $_POST['fields'] )
				? map_deep( wp_unslash( $_POST['fields'] ), 'sanitize_text_field' )
				: wp_strip_all_tags( wp_unslash( (string) $_POST['fields'] ), true );
			if ( is_string( $fields_raw ) ) {
				$decoded           = json_decode( $fields_raw, true );
				$payload['fields'] = is_array( $decoded ) ? Sanitizer::sanitize_schema_data( $decoded ) : array();
			} elseif ( is_array( $fields_raw ) ) {
				$payload['fields'] = $fields_raw;
			}
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		return $payload;
	}

	// -----------------------------------------------------------------------
	// REST handlers
	// -----------------------------------------------------------------------

	/**
	 * REST: list.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function rest_list( $request ) {
		return self::rest_dispatch( 'list', $request );
	}

	/**
	 * REST: create.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function rest_create( $request ) {
		return self::rest_dispatch( 'create', $request );
	}

	/**
	 * REST: update.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function rest_update( $request ) {
		return self::rest_dispatch( 'update', $request );
	}

	/**
	 * REST: delete.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function rest_delete( $request ) {
		return self::rest_dispatch( 'delete', $request );
	}

	/**
	 * REST: preview.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function rest_preview( $request ) {
		return self::rest_dispatch( 'preview', $request );
	}

	/**
	 * REST: versioned form/instance contract for the post.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function rest_contract( $request ) {
		$post_id = (int) $request->get_param( 'id' );
		$client  = $request->get_header( 'X-Schema-Instance-Contract-Version' );
		if ( null === $client || '' === $client ) {
			$client = $request->get_param( 'client_contract_version' );
		}
		$version_gate = SchemaInstanceConnectorContract::assert_client_version( $client );
		if ( true !== $version_gate ) {
			return new \WP_Error(
				'seovault_' . $version_gate['code'],
				$version_gate['message'],
				array(
					'status'    => (int) $version_gate['status'],
					'supported' => $version_gate['supported'],
				)
			);
		}

		$post_gate = self::assert_post_accessible( $post_id, 'rest', 'contract' );
		if ( empty( $post_gate['ok'] ) ) {
			return new \WP_Error(
				'seovault_' . $post_gate['code'],
				$post_gate['message'],
				array( 'status' => isset( $post_gate['status'] ) ? (int) $post_gate['status'] : 400 )
			);
		}

		$type  = (string) $request->get_param( 'type' );
		$built = SchemaInstanceConnectorContract::build_for_post(
			$post_id,
			array(
				'connector' => true,
				'type'      => $type,
			)
		);
		if ( empty( $built['ok'] ) ) {
			return new \WP_Error(
				'seovault_' . $built['code'],
				$built['message'],
				array( 'status' => isset( $built['status'] ) ? (int) $built['status'] : 400 )
			);
		}

		return rest_ensure_response( $built['data'] );
	}

	/**
	 * Shared REST dispatcher.
	 *
	 * @param string           $op      Operation.
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	private static function rest_dispatch( $op, $request ) {
		$post_id = absint( $request->get_param( 'id' ) );
		$body    = $request->get_json_params();
		if ( ! is_array( $body ) ) {
			$body = $request->get_body_params();
		}
		if ( ! is_array( $body ) ) {
			$body = array();
		}

		$raw_body = $request->get_body();
		if ( is_string( $raw_body ) && '' !== $raw_body ) {
			$body['_payload_bytes'] = strlen( $raw_body );
			if ( null === $request->get_json_params() && '' !== trim( $raw_body ) && '{' === substr( trim( $raw_body ), 0, 1 ) ) {
				$body['_malformed_json'] = true;
			}
		}

		$instance_id = (string) $request->get_param( 'instance_id' );
		if ( '' !== $instance_id && empty( $body['instance_id'] ) ) {
			$body['instance_id'] = $instance_id;
		}

		$header_idem = $request->get_header( 'Idempotency-Key' );
		if ( is_string( $header_idem ) && '' !== $header_idem && empty( $body['idempotency_key'] ) ) {
			$body['idempotency_key'] = $header_idem;
		}

		$result = self::handle(
			$op,
			$post_id,
			$body,
			array(
				'channel' => 'rest',
			)
		);

		if ( empty( $result['ok'] ) ) {
			return new \WP_Error(
				'seovault_' . $result['code'],
				$result['message'],
				array(
					'status'   => isset( $result['status'] ) ? (int) $result['status'] : 400,
					'errors'   => isset( $result['errors'] ) ? $result['errors'] : array(),
					'conflict' => isset( $result['conflict'] ) ? $result['conflict'] : null,
				)
			);
		}

		return rest_ensure_response( SchemaInstanceConnectorContract::envelope( $result['data'] ) );
	}

	// -----------------------------------------------------------------------
	// Core operations
	// -----------------------------------------------------------------------

	/**
	 * Handle one instance operation.
	 *
	 * @param string               $op      list|create|update|delete|preview.
	 * @param int                  $post_id Post ID.
	 * @param array<string,mixed>  $payload Request payload.
	 * @param array<string,mixed>  $context Channel context.
	 * @return array{ok:bool,code?:string,message?:string,status?:int,errors?:string[],conflict?:array|null,data?:array}
	 */
	public static function handle( $op, $post_id, array $payload, $context = array() ) {
		$op      = (string) $op;
		$post_id = (int) $post_id;
		$channel = isset( $context['channel'] ) ? (string) $context['channel'] : 'ajax';

		if ( ! empty( $payload['_malformed_json'] ) ) {
			return self::fail( 'malformed_payload', __( 'Request payload was malformed.', 'seovault-ai' ), 400 );
		}

		$bytes = isset( $payload['_payload_bytes'] ) ? (int) $payload['_payload_bytes'] : strlen( (string) wp_json_encode( $payload ) );
		$max   = self::max_request_bytes();
		if ( $bytes > $max ) {
			return self::fail( 'payload_too_large', __( 'Schema instance payload is too large.', 'seovault-ai' ), 413 );
		}

		$post_gate = self::assert_post_accessible( $post_id, $channel, $op );
		if ( ! $post_gate['ok'] ) {
			return $post_gate;
		}

		switch ( $op ) {
			case 'list':
				return self::op_list( $post_id );
			case 'preview':
				return self::op_preview( $post_id, $payload );
			case 'create':
				return self::op_create( $post_id, $payload, $channel );
			case 'update':
				return self::op_update( $post_id, $payload, $channel );
			case 'delete':
				return self::op_delete( $post_id, $payload, $channel );
			default:
				return self::fail( 'unknown_operation', __( 'Unknown schema instance operation.', 'seovault-ai' ), 400 );
		}
	}

	/**
	 * List stored instances for a post.
	 *
	 * @param int $post_id Post ID.
	 * @return array
	 */
	private static function op_list( $post_id ) {
		$doc = SchemaInstanceStorage::read_document( $post_id );
		$out = array();
		foreach ( $doc['instances'] as $instance ) {
			if ( ! is_array( $instance ) ) {
				continue;
			}
			$out[] = self::present_instance( $instance );
		}

		return self::ok(
			array_merge(
				self::post_sync_meta( $post_id ),
				array(
					'version'   => SchemaInstanceStorage::DOCUMENT_VERSION,
					'instances' => $out,
				)
			)
		);
	}

	/**
	 * Preview sanitized fields + readiness without persisting.
	 *
	 * @param int                 $post_id Post ID.
	 * @param array<string,mixed> $payload Payload.
	 * @return array
	 */
	private static function op_preview( $post_id, array $payload ) {
		$type = (string) ( $payload['type'] ?? '' );
		if ( '' === $type && ! empty( $payload['instance_id'] ) ) {
			$existing = self::find_instance( $post_id, (string) $payload['instance_id'] );
			if ( $existing ) {
				$type = (string) ( $existing['type'] ?? '' );
			}
		}

		if ( '' === $type || ! SchemaInstanceFieldSanitizer::is_guided_or_manual_product_type( $type ) ) {
			return self::fail( 'unsupported_type', __( 'That schema type cannot be stored as a guided instance.', 'seovault-ai' ), 400, array( 'unsupported_type' ) );
		}

		$fields_raw = isset( $payload['fields'] ) ? $payload['fields'] : array();
		$sanitized  = SchemaInstanceFieldSanitizer::sanitize( $type, $fields_raw );
		if ( empty( $sanitized['ok'] ) ) {
			return self::fail(
				'invalid_fields',
				__( 'Schema fields failed validation.', 'seovault-ai' ),
				400,
				$sanitized['errors']
			);
		}

		$source_meta = isset( $payload['source_meta'] ) && is_array( $payload['source_meta'] )
			? SchemaInstanceStorage::sanitize_source_meta( $payload['source_meta'] )
			: array();
		$readiness   = SchemaReadinessEvaluator::evaluate(
			$type,
			$sanitized['fields'],
			self::visibility_eval_context( $source_meta )
		);
		$preview     = array(
			'id'          => isset( $payload['instance_id'] ) ? (string) $payload['instance_id'] : '',
			'type'        => $type,
			'source'      => SchemaInstanceStorage::sanitize_source( $payload['source'] ?? 'manual' ),
			'fields'      => $sanitized['fields'],
			'source_meta' => $source_meta,
		);

		return self::ok(
			array_merge(
				self::post_sync_meta( $post_id ),
				array(
					'instance'  => self::present_instance( $preview, $readiness ),
					'readiness' => $readiness->to_array(),
					'setup'     => self::setup_diagnostics( $readiness ),
					'persisted' => false,
					'stripped'  => $sanitized['stripped'],
				)
			)
		);
	}

	/**
	 * Create a draft/ready instance.
	 *
	 * @param int                 $post_id Post ID.
	 * @param array<string,mixed> $payload Payload.
	 * @param string              $channel ajax|rest.
	 * @return array
	 */
	private static function op_create( $post_id, array $payload, $channel ) {
		$stale = self::assert_fresh_content( $post_id, $payload );
		if ( ! $stale['ok'] ) {
			return $stale;
		}

		$idem_key = isset( $payload['idempotency_key'] ) ? (string) $payload['idempotency_key'] : '';
		$idem_key = self::normalize_idempotency_key( $idem_key );
		if ( '' !== $idem_key ) {
			$cached = self::get_idempotent_result( $post_id, $idem_key );
			if ( is_array( $cached ) ) {
				return self::ok(
					array_merge(
						$cached,
						array(
							'idempotent_replay' => true,
						)
					)
				);
			}
		}

		$type = (string) ( $payload['type'] ?? '' );
		if ( '' === $type || ! SchemaInstanceFieldSanitizer::is_guided_or_manual_product_type( $type ) ) {
			return self::fail( 'unsupported_type', __( 'That schema type cannot be stored as a guided instance.', 'seovault-ai' ), 400, array( 'unsupported_type' ) );
		}

		$write = SchemaInstanceStorage::upsert_instance(
			$post_id,
			array(
				'type'        => $type,
				'source'      => isset( $payload['source'] ) ? $payload['source'] : ( 'rest' === $channel ? 'api' : 'manual' ),
				'fields'      => isset( $payload['fields'] ) ? $payload['fields'] : array(),
				'source_meta' => isset( $payload['source_meta'] ) && is_array( $payload['source_meta'] ) ? $payload['source_meta'] : array(),
			),
			array(
				'skip_cap_check' => true, // Already authorized at endpoint boundary.
			)
		);

		if ( empty( $write['ok'] ) || ! is_array( $write['instance'] ) ) {
			return self::map_storage_errors( $write['errors'] );
		}

		$instance  = $write['instance'];
		$readiness = SchemaReadinessEvaluator::evaluate(
			(string) $instance['type'],
			isset( $instance['fields'] ) && is_array( $instance['fields'] ) ? $instance['fields'] : array(),
			self::visibility_eval_context(
				isset( $instance['source_meta'] ) && is_array( $instance['source_meta'] ) ? $instance['source_meta'] : array()
			)
		);

		$data = array_merge(
			self::post_sync_meta( $post_id ),
			array(
				'instance'          => self::present_instance( $instance, $readiness ),
				'readiness'         => $readiness->to_array(),
				'setup'             => self::setup_diagnostics( $readiness ),
				'persisted'         => true,
				'idempotent_replay' => false,
			)
		);

		if ( '' !== $idem_key ) {
			self::store_idempotent_result( $post_id, $idem_key, $data );
		}

		return self::ok( $data );
	}

	/**
	 * Update an existing instance.
	 *
	 * @param int                 $post_id Post ID.
	 * @param array<string,mixed> $payload Payload.
	 * @param string              $channel Channel.
	 * @return array
	 */
	private static function op_update( $post_id, array $payload, $channel ) {
		unset( $channel );
		$stale = self::assert_fresh_content( $post_id, $payload );
		if ( ! $stale['ok'] ) {
			return $stale;
		}

		$instance_id = (string) ( $payload['instance_id'] ?? $payload['id'] ?? '' );
		if ( '' === $instance_id ) {
			return self::fail( 'missing_instance_id', __( 'Instance id is required.', 'seovault-ai' ), 400 );
		}

		$existing = self::find_instance( $post_id, $instance_id );
		if ( ! $existing ) {
			return self::fail( 'instance_not_found', __( 'Schema instance was not found.', 'seovault-ai' ), 404 );
		}

		$write = SchemaInstanceStorage::upsert_instance(
			$post_id,
			array(
				'id'          => $instance_id,
				'type'        => isset( $payload['type'] ) ? $payload['type'] : $existing['type'],
				'source'      => isset( $payload['source'] ) ? $payload['source'] : ( $existing['source'] ?? 'manual' ),
				'fields'      => array_key_exists( 'fields', $payload ) ? $payload['fields'] : ( $existing['fields'] ?? array() ),
				'source_meta' => isset( $payload['source_meta'] ) && is_array( $payload['source_meta'] )
					? $payload['source_meta']
					: ( isset( $existing['source_meta'] ) && is_array( $existing['source_meta'] ) ? $existing['source_meta'] : array() ),
			),
			array(
				'skip_cap_check' => true,
			)
		);

		if ( empty( $write['ok'] ) || ! is_array( $write['instance'] ) ) {
			return self::map_storage_errors( $write['errors'] );
		}

		$instance  = $write['instance'];
		$readiness = SchemaReadinessEvaluator::evaluate(
			(string) $instance['type'],
			isset( $instance['fields'] ) && is_array( $instance['fields'] ) ? $instance['fields'] : array(),
			self::visibility_eval_context(
				isset( $instance['source_meta'] ) && is_array( $instance['source_meta'] ) ? $instance['source_meta'] : array()
			)
		);

		return self::ok(
			array_merge(
				self::post_sync_meta( $post_id ),
				array(
					'instance'  => self::present_instance( $instance, $readiness ),
					'readiness' => $readiness->to_array(),
					'setup'     => self::setup_diagnostics( $readiness ),
					'persisted' => true,
				)
			)
		);
	}

	/**
	 * Delete an instance.
	 *
	 * @param int                 $post_id Post ID.
	 * @param array<string,mixed> $payload Payload.
	 * @param string              $channel Channel.
	 * @return array
	 */
	private static function op_delete( $post_id, array $payload, $channel ) {
		unset( $channel );
		$stale = self::assert_fresh_content( $post_id, $payload );
		if ( ! $stale['ok'] ) {
			return $stale;
		}

		$instance_id = (string) ( $payload['instance_id'] ?? $payload['id'] ?? '' );
		if ( '' === $instance_id ) {
			return self::fail( 'missing_instance_id', __( 'Instance id is required.', 'seovault-ai' ), 400 );
		}

		$deleted = SchemaInstanceStorage::delete_instance(
			$post_id,
			$instance_id,
			array(
				'skip_cap_check' => true,
			)
		);

		if ( empty( $deleted['ok'] ) ) {
			if ( in_array( 'invalid_or_unknown_instance_id', $deleted['errors'], true ) ) {
				return self::fail( 'instance_not_found', __( 'Schema instance was not found.', 'seovault-ai' ), 404 );
			}
			return self::map_storage_errors( $deleted['errors'] );
		}

		return self::ok(
			array_merge(
				self::post_sync_meta( $post_id ),
				array(
					'deleted'     => true,
					'instance_id' => $instance_id,
				)
			)
		);
	}

	// -----------------------------------------------------------------------
	// Guards / helpers
	// -----------------------------------------------------------------------

	/**
	 * Validate post exists and caller may access it.
	 *
	 * @param int    $post_id Post ID.
	 * @param string $channel ajax|rest.
	 * @param string $op      Operation.
	 * @return array
	 */
	private static function assert_post_accessible( $post_id, $channel, $op ) {
		if ( $post_id <= 0 ) {
			return self::fail( 'invalid_post', __( 'Invalid post.', 'seovault-ai' ), 400 );
		}

		$post = get_post( $post_id );
		if ( ! $post ) {
			return self::fail( 'invalid_post', __( 'Post not found.', 'seovault-ai' ), 404 );
		}

		if ( 'trash' === $post->post_status ) {
			return self::fail( 'post_trashed', __( 'Cannot modify schema on a trashed post.', 'seovault-ai' ), 409 );
		}

		if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) || 'revision' === $post->post_type ) {
			return self::fail( 'revision_or_autosave_not_allowed', __( 'Schema instances cannot be stored on revisions or autosaves.', 'seovault-ai' ), 400 );
		}

		if ( 'ajax' === $channel ) {
			if ( ! current_user_can( 'edit_post', $post_id ) ) {
				Logger::error( 'SchemaInstanceEndpoints: edit_post denied', array( 'post_id' => $post_id, 'op' => $op ) );
				return self::fail( 'forbidden', __( 'You do not have permission to edit this post.', 'seovault-ai' ), 403 );
			}
		}

		// REST channel: site-key already validated; still require the post to be a normal editable type.
		$public_types = get_post_types( array( 'public' => true ), 'names' );
		if ( ! in_array( $post->post_type, $public_types, true ) && 'page' !== $post->post_type && 'post' !== $post->post_type ) {
			// Allow any type that supports editor custom fields except internal types already rejected.
			if ( ! post_type_supports( $post->post_type, 'custom-fields' ) && ! post_type_supports( $post->post_type, 'editor' ) ) {
				return self::fail( 'invalid_post', __( 'This post type does not support schema instances.', 'seovault-ai' ), 400 );
			}
		}

		return array( 'ok' => true );
	}

	/**
	 * Reject stale writes when client supplies a content hash.
	 *
	 * @param int                 $post_id Post ID.
	 * @param array<string,mixed> $payload Payload.
	 * @return array
	 */
	private static function assert_fresh_content( $post_id, array $payload ) {
		$meta = self::post_sync_meta( $post_id );

		if ( isset( $payload['content_hash'] ) && '' !== (string) $payload['content_hash'] ) {
			$provided = strtolower( (string) $payload['content_hash'] );
			if ( ! hash_equals( (string) $meta['content_hash'], $provided ) ) {
				return self::fail(
					'stale_content',
					__( 'This post changed since you loaded the schema editor. Reload and try again.', 'seovault-ai' ),
					409,
					array( 'stale_content' ),
					$meta
				);
			}
		}

		if ( isset( $payload['post_modified_gmt'] ) && '' !== (string) $payload['post_modified_gmt'] ) {
			if ( (string) $meta['post_modified_gmt'] !== (string) $payload['post_modified_gmt'] ) {
				return self::fail(
					'stale_content',
					__( 'This post changed since you loaded the schema editor. Reload and try again.', 'seovault-ai' ),
					409,
					array( 'stale_content' ),
					$meta
				);
			}
		}

		return array( 'ok' => true );
	}

	/**
	 * Content sync metadata for clients.
	 *
	 * @param int $post_id Post ID.
	 * @return array{content_hash:string,post_modified_gmt:string,post_id:int}
	 */
	public static function post_sync_meta( $post_id ) {
		$post = get_post( (int) $post_id );
		$content = $post ? (string) $post->post_content : '';
		return array(
			'post_id'           => (int) $post_id,
			'content_hash'      => hash( 'sha256', $content ),
			'post_modified_gmt' => $post ? (string) $post->post_modified_gmt : '',
		);
	}

	/**
	 * Find one instance by id.
	 *
	 * @param int    $post_id Post ID.
	 * @param string $id      Instance id.
	 * @return array<string,mixed>|null
	 */
	private static function find_instance( $post_id, $id ) {
		$doc = SchemaInstanceStorage::read_document( $post_id );
		foreach ( $doc['instances'] as $instance ) {
			if ( is_array( $instance ) && (string) ( $instance['id'] ?? '' ) === (string) $id ) {
				return $instance;
			}
		}
		return null;
	}

	/**
	 * Present a canonical instance for API responses.
	 *
	 * @param array                                           $instance  Instance.
	 * @param \SEOvaultSEO\Schema\Readiness\SchemaReadinessResult|null $readiness Optional readiness.
	 * @return array<string,mixed>
	 */
	public static function present_instance( array $instance, $readiness = null ) {
		$type   = (string) ( $instance['type'] ?? '' );
		$fields = isset( $instance['fields'] ) && is_array( $instance['fields'] ) ? $instance['fields'] : array();
		$meta   = isset( $instance['source_meta'] ) && is_array( $instance['source_meta'] ) ? $instance['source_meta'] : array();
		if ( null === $readiness && '' !== $type ) {
			$readiness = SchemaReadinessEvaluator::evaluate( $type, $fields, self::visibility_eval_context( $meta ) );
		}

		$out = array(
			'id'         => (string) ( $instance['id'] ?? '' ),
			'type'       => $type,
			'source'     => (string) ( $instance['source'] ?? 'manual' ),
			'fields'     => $fields,
			'created_at' => isset( $instance['created_at'] ) ? (int) $instance['created_at'] : 0,
			'updated_at' => isset( $instance['updated_at'] ) ? (int) $instance['updated_at'] : 0,
		);

		if ( $readiness ) {
			$out['status']    = $readiness->is_ready() ? 'ready' : 'draft';
			$out['readiness'] = array(
				'ready'             => $readiness->is_ready(),
				'policy_version'    => $readiness->get_policy_version(),
				'blocker_count'     => count( $readiness->get_blockers() ),
				'improvement_count' => count( $readiness->get_improvements() ),
			);
		} elseif ( isset( $instance['status'] ) ) {
			$out['status'] = sanitize_key( (string) $instance['status'] );
		}

		if ( isset( $instance['source_meta'] ) && is_array( $instance['source_meta'] ) ) {
			$out['source_meta'] = $instance['source_meta'];
		}
		if ( isset( $instance['migration'] ) && is_array( $instance['migration'] ) ) {
			$out['migration'] = $instance['migration'];
		}

		return $out;
	}

	/**
	 * Readiness context that enforces visible-content confirmation for guided instances.
	 *
	 * @param array<string,mixed> $source_meta Source meta bag.
	 * @return array{enforce_visibility:bool,source_meta:array<string,mixed>}
	 */
	private static function visibility_eval_context( array $source_meta ) {
		return array(
			'enforce_visibility' => true,
			'source_meta'        => $source_meta,
		);
	}

	/**
	 * Setup diagnostics block for unfinished vs ready.
	 *
	 * @param \SEOvaultSEO\Schema\Readiness\SchemaReadinessResult $readiness Result.
	 * @return array<string,mixed>
	 */
	private static function setup_diagnostics( $readiness ) {
		$blockers = array();
		foreach ( $readiness->get_blockers() as $issue ) {
			$blockers[] = $issue->to_array();
		}
		$improvements = array();
		foreach ( $readiness->get_improvements() as $issue ) {
			$improvements[] = $issue->to_array();
		}

		return array(
			'state'         => $readiness->is_ready() ? 'ready' : 'unfinished_setup',
			'emitted'       => $readiness->is_ready(),
			'blockers'      => $blockers,
			'improvements'  => $improvements,
			'policy_version'=> $readiness->get_policy_version(),
		);
	}

	/**
	 * Map storage error codes to API failures.
	 *
	 * @param string[] $errors Errors.
	 * @return array
	 */
	private static function map_storage_errors( array $errors ) {
		if ( in_array( 'unknown_type', $errors, true ) ) {
			return self::fail( 'unsupported_type', __( 'That schema type cannot be stored as a guided instance.', 'seovault-ai' ), 400, $errors );
		}
		if ( in_array( 'too_many_instances', $errors, true ) ) {
			return self::fail( 'too_many_instances', __( 'This post already has the maximum number of schema instances.', 'seovault-ai' ), 400, $errors );
		}
		if ( in_array( 'payload_too_large', $errors, true ) || in_array( 'document_too_large', $errors, true ) ) {
			return self::fail( 'payload_too_large', __( 'Schema instance payload is too large.', 'seovault-ai' ), 413, $errors );
		}
		if ( in_array( 'forbidden', $errors, true ) ) {
			return self::fail( 'forbidden', __( 'You do not have permission to edit this post.', 'seovault-ai' ), 403, $errors );
		}
		return self::fail( 'storage_rejected', __( 'Schema instance could not be saved.', 'seovault-ai' ), 400, $errors );
	}

	/**
	 * Normalize idempotency key.
	 *
	 * @param string $key Raw.
	 * @return string
	 */
	private static function normalize_idempotency_key( $key ) {
		$key = trim( (string) $key );
		if ( '' === $key ) {
			return '';
		}
		if ( strlen( $key ) > 128 ) {
			$key = substr( $key, 0, 128 );
		}
		return preg_replace( '/[^a-zA-Z0-9._:-]/', '', $key );
	}

	/**
	 * Transient key for idempotency.
	 *
	 * @param int    $post_id Post ID.
	 * @param string $key     Idempotency key.
	 * @return string
	 */
	private static function idempotency_transient_key( $post_id, $key ) {
		return 'sv_si_idem_' . md5( (int) $post_id . '|' . $key );
	}

	/**
	 * Load cached create response.
	 *
	 * @param int    $post_id Post ID.
	 * @param string $key     Key.
	 * @return array|null
	 */
	private static function get_idempotent_result( $post_id, $key ) {
		$cached = get_transient( self::idempotency_transient_key( $post_id, $key ) );
		return is_array( $cached ) ? $cached : null;
	}

	/**
	 * Store create response for idempotent replay.
	 *
	 * @param int    $post_id Post ID.
	 * @param string $key     Key.
	 * @param array  $data    Response data.
	 * @return void
	 */
	private static function store_idempotent_result( $post_id, $key, array $data ) {
		set_transient( self::idempotency_transient_key( $post_id, $key ), $data, self::IDEMPOTENCY_TTL );
	}

	/**
	 * Max request bytes.
	 *
	 * @return int
	 */
	private static function max_request_bytes() {
		/**
		 * Filters max schema-instance request body size.
		 *
		 * @param int $default Soft default.
		 */
		$value = (int) apply_filters( 'seovaultseo_schema_instance_max_request_bytes', self::DEFAULT_MAX_REQUEST_BYTES );
		return max( 1024, min( $value, self::HARD_MAX_REQUEST_BYTES ) );
	}

	/**
	 * Success envelope.
	 *
	 * @param array $data Data.
	 * @return array{ok:bool,data:array}
	 */
	private static function ok( array $data ) {
		return array(
			'ok'   => true,
			'data' => $data,
		);
	}

	/**
	 * Failure envelope.
	 *
	 * @param string      $code     Code.
	 * @param string      $message  Message.
	 * @param int         $status   HTTP status.
	 * @param string[]    $errors   Errors.
	 * @param array|null  $conflict Conflict meta.
	 * @return array
	 */
	private static function fail( $code, $message, $status = 400, array $errors = array(), $conflict = null ) {
		return array(
			'ok'       => false,
			'code'     => (string) $code,
			'message'  => (string) $message,
			'status'   => (int) $status,
			'errors'   => $errors,
			'conflict' => $conflict,
		);
	}
}
