<?php
/**
 * Schema instance / guided-form connector contract.
 *
 * Versioned handshake + post-aware form metadata for web clients.
 * Does not expose internal PHP classes, callbacks, or secrets.
 *
 * @package SEOvaultSEO\Api
 */

namespace SEOvaultSEO\Api;

use SEOvaultSEO\Admin\SchemaAddPicker;
use SEOvaultSEO\Schema\Readiness\SchemaReadinessEvaluator;
use SEOvaultSEO\Schema\SchemaGuidedFormSchema;
use SEOvaultSEO\Schema\SchemaInstanceEndpoints;
use SEOvaultSEO\Schema\SchemaInstanceStorage;
use SEOvaultSEO\Schema\SchemaTypePolicyRegistry;

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

/**
 * Canonical guided-instance contract for connected publishing clients.
 */
class SchemaInstanceConnectorContract {

	/**
	 * Contract version advertised on verify + contract responses.
	 * Bump when the instance/form contract changes incompatibly.
	 */
	const CONTRACT_VERSION = 1;

	/**
	 * Form-schema metadata shape version (field control envelope).
	 */
	const FORM_SCHEMA_VERSION = 1;

	/**
	 * Document meta version for `_seovault_schema_instances_v2`.
	 */
	const DOCUMENT_VERSION = 2;

	/**
	 * Minimum client contract version this server accepts.
	 */
	const MIN_CLIENT_CONTRACT_VERSION = 1;

	/**
	 * Built-in guided types advertised in the handshake.
	 *
	 * @var string[]
	 */
	const GUIDED_TYPES = array(
		'Event',
		'Recipe',
		'Course',
		'JobPosting',
		'SoftwareApplication',
		'Review',
	);

	/**
	 * Public handshake fields for `/verify` and write envelopes.
	 *
	 * @return array<string,mixed>
	 */
	public static function handshake() {
		return array(
			'schema_instance_contract_version' => self::CONTRACT_VERSION,
			'guided_form_schema_version'       => self::FORM_SCHEMA_VERSION,
			'schema_instance_document_version' => self::DOCUMENT_VERSION,
			'schema_instance_crud'             => true,
			'schema_instance_preview'          => true,
			'schema_instance_form_contract'    => true,
			'content_hash_guard'               => true,
			'idempotency_guard'                => true,
			'readiness_policy_version'         => SchemaReadinessEvaluator::POLICY_VERSION,
			'supported_guided_types'           => self::GUIDED_TYPES,
			'schema_instance_limits'           => self::limits(),
			'min_schema_instance_client_version' => self::MIN_CLIENT_CONTRACT_VERSION,
		);
	}

	/**
	 * Soft limits exposed to clients (no secrets).
	 *
	 * @return array<string,int>
	 */
	public static function limits() {
		return array(
			'max_instances_per_post' => (int) SchemaInstanceStorage::DEFAULT_MAX_INSTANCES,
			'max_request_bytes'      => (int) SchemaInstanceEndpoints::DEFAULT_MAX_REQUEST_BYTES,
			'max_document_bytes'     => (int) SchemaInstanceStorage::DEFAULT_MAX_DOCUMENT_BYTES,
		);
	}

	/**
	 * Wrap instance CRUD/list/preview payloads with contract metadata.
	 *
	 * @param array<string,mixed> $data Operation data.
	 * @return array<string,mixed>
	 */
	public static function envelope( array $data ) {
		return array_merge(
			array(
				'contract_version'         => self::CONTRACT_VERSION,
				'form_schema_version'      => self::FORM_SCHEMA_VERSION,
				'readiness_policy_version' => SchemaReadinessEvaluator::POLICY_VERSION,
				'document_version'         => self::DOCUMENT_VERSION,
			),
			$data
		);
	}

	/**
	 * Whether a client contract version is supported.
	 *
	 * @param mixed $client_version Client-reported version.
	 * @return true|array{ok:false,code:string,message:string,status:int,supported:array}
	 */
	public static function assert_client_version( $client_version ) {
		if ( null === $client_version || '' === $client_version ) {
			return true;
		}
		if ( ! is_numeric( $client_version ) ) {
			return array(
				'ok'        => false,
				'code'      => 'unsupported_contract_version',
				'message'   => __( 'Unsupported schema instance contract version.', 'seovault-ai' ),
				'status'    => 400,
				'supported' => array(
					'min' => self::MIN_CLIENT_CONTRACT_VERSION,
					'max' => self::CONTRACT_VERSION,
				),
			);
		}
		$version = (int) $client_version;
		if ( $version < self::MIN_CLIENT_CONTRACT_VERSION || $version > self::CONTRACT_VERSION ) {
			return array(
				'ok'        => false,
				'code'      => 'unsupported_contract_version',
				'message'   => __( 'Unsupported schema instance contract version.', 'seovault-ai' ),
				'status'    => 400,
				'supported' => array(
					'min' => self::MIN_CLIENT_CONTRACT_VERSION,
					'max' => self::CONTRACT_VERSION,
				),
			);
		}
		return true;
	}

	/**
	 * Build the post-aware form + instance contract for web clients.
	 *
	 * @param int                 $post_id Post ID.
	 * @param array<string,mixed> $options Options (`connector` => bool, `type` => optional filter).
	 * @return array{ok:bool,code?:string,message?:string,status?:int,data?:array<string,mixed>}
	 */
	public static function build_for_post( $post_id, array $options = array() ) {
		$post_id = absint( $post_id );
		$post    = $post_id ? get_post( $post_id ) : null;
		if ( ! $post instanceof \WP_Post ) {
			return array(
				'ok'      => false,
				'code'    => 'invalid_post',
				'message' => __( 'Post not found.', 'seovault-ai' ),
				'status'  => 404,
			);
		}

		$context = SchemaAddPicker::build_context( $post_id );
		if ( ! empty( $options['connector'] ) ) {
			// Site-key REST already authorized; do not require a WP user session.
			$context['can_edit'] = true;
		}

		$picker = SchemaAddPicker::get_groups( $context );
		$sync   = SchemaInstanceEndpoints::post_sync_meta( $post_id );

		$type_filter = isset( $options['type'] ) ? (string) $options['type'] : '';
		$form_schemas = self::collect_form_schemas( $picker, $type_filter );

		$list = SchemaInstanceEndpoints::handle( 'list', $post_id, array(), array( 'channel' => ! empty( $options['connector'] ) ? 'rest' : 'ajax' ) );
		$instances = ( ! empty( $list['ok'] ) && isset( $list['data']['instances'] ) && is_array( $list['data']['instances'] ) )
			? $list['data']['instances']
			: array();

		$ownership = self::collect_integration_ownership( $picker );

		$data = array(
			'ok'                       => true,
			'contract_version'         => self::CONTRACT_VERSION,
			'form_schema_version'      => self::FORM_SCHEMA_VERSION,
			'readiness_policy_version' => SchemaReadinessEvaluator::POLICY_VERSION,
			'document_version'         => self::DOCUMENT_VERSION,
			'min_client_contract_version' => self::MIN_CLIENT_CONTRACT_VERSION,
			'post_id'                  => $post_id,
			'content_hash'             => $sync['content_hash'],
			'post_modified_gmt'        => $sync['post_modified_gmt'],
			'context'                  => array(
				'post_type'           => (string) ( $context['post_type'] ?? '' ),
				'supported_post_type' => ! empty( $context['supported_post_type'] ),
				'woo_active'          => ! empty( $context['woo_active'] ),
				'schema_enabled'      => ! empty( $context['schema_enabled'] ),
				'v2_enabled'          => ! empty( $context['v2_enabled'] ),
				'seo_output_enabled'  => ! empty( $context['seo_output_enabled'] ),
			),
			'capabilities'             => array(
				'can_create'       => ! empty( $picker['available'] ),
				'can_preview'      => true,
				'can_list'         => true,
				'client_validity'  => 'advisory_only',
				'requires_visibility_confirmation' => true,
			),
			'limits'                   => self::limits(),
			'picker'                   => self::public_picker( $picker ),
			'integration_ownership'    => $ownership,
			'form_schemas'             => $form_schemas,
			'instances'                => $instances,
			'endpoints'                => array(
				'list'     => '/posts/{id}/schema-instances',
				'create'   => '/posts/{id}/schema-instances',
				'preview'  => '/posts/{id}/schema-instances/preview',
				'update'   => '/posts/{id}/schema-instances/{instance_id}',
				'delete'   => '/posts/{id}/schema-instances/{instance_id}/delete',
				'contract' => '/posts/{id}/schema-instance-contract',
			),
		);

		return array(
			'ok'   => true,
			'data' => self::sanitize_public_payload( $data ),
		);
	}

	/**
	 * Collect public form schemas for available guided types.
	 *
	 * @param array  $picker      Picker groups.
	 * @param string $type_filter Optional single type.
	 * @return array<string,array<string,mixed>>
	 */
	private static function collect_form_schemas( array $picker, $type_filter ) {
		$out   = array();
		$types = array();

		if ( ! empty( $picker['available'] ) ) {
			foreach ( self::GUIDED_TYPES as $type ) {
				$types[ $type ] = true;
			}
			foreach ( (array) ( $picker['groups'] ?? array() ) as $group ) {
				foreach ( (array) ( $group['actions'] ?? array() ) as $action ) {
					if ( ! is_array( $action ) ) {
						continue;
					}
					$type = (string) ( $action['type'] ?? '' );
					if ( '' === $type ) {
						continue;
					}
					if ( 'integration_owned' === (string) ( $action['action'] ?? '' ) ) {
						unset( $types[ $type ] );
						continue;
					}
					if ( 'open_guided_form' === (string) ( $action['action'] ?? '' ) || ! empty( $action['fallback'] ) ) {
						$types[ $type ] = true;
					}
				}
			}
		}

		if ( '' !== $type_filter ) {
			$types = isset( $types[ $type_filter ] ) ? array( $type_filter => true ) : array();
		}

		foreach ( array_keys( $types ) as $type ) {
			$built = SchemaGuidedFormSchema::build_for_type( $type );
			if ( empty( $built['ok'] ) ) {
				continue;
			}
			$entry = SchemaTypePolicyRegistry::get_entry( $type );
			$out[ $type ] = array(
				'ok'                   => true,
				'type'                 => $type,
				'label'                => (string) ( $built['label'] ?? $type ),
				'help'                 => (string) ( $built['help'] ?? '' ),
				'picker_description'   => (string) ( $built['picker_description'] ?? '' ),
				'requires_visibility'  => ! empty( $built['requires_visibility'] ),
				'supported_controls'   => isset( $built['supported_controls'] ) && is_array( $built['supported_controls'] ) ? array_values( $built['supported_controls'] ) : array(),
				'client_validity_note' => 'advisory_only',
				'fields'               => isset( $built['fields'] ) && is_array( $built['fields'] ) ? $built['fields'] : array(),
				'policy'               => array(
					'category'                 => is_array( $entry ) ? (string) ( $entry['category'] ?? '' ) : '',
					'editor_method'            => is_array( $entry ) ? (string) ( $entry['editor_method'] ?? '' ) : '',
					'allowed_sources'          => is_array( $entry ) && isset( $entry['allowed_sources'] ) && is_array( $entry['allowed_sources'] ) ? array_values( $entry['allowed_sources'] ) : array(),
					'documentation_key'        => is_array( $entry ) ? (string) ( $entry['documentation_key'] ?? '' ) : '',
					'requires_visible_content' => is_array( $entry ) ? ! empty( $entry['requires_visible_content'] ) : true,
				),
			);
		}

		return $out;
	}

	/**
	 * Public picker projection (no internal callbacks).
	 *
	 * @param array $picker Picker result.
	 * @return array<string,mixed>
	 */
	private static function public_picker( array $picker ) {
		$groups = array();
		foreach ( (array) ( $picker['groups'] ?? array() ) as $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			$actions = array();
			foreach ( (array) ( $group['actions'] ?? array() ) as $action ) {
				if ( ! is_array( $action ) ) {
					continue;
				}
				$actions[] = array(
					'id'           => (string) ( $action['id'] ?? '' ),
					'type'         => (string) ( $action['type'] ?? '' ),
					'label'        => (string) ( $action['label'] ?? '' ),
					'group'        => (string) ( $action['group'] ?? '' ),
					'action'       => (string) ( $action['action'] ?? '' ),
					'enabled'      => ! empty( $action['enabled'] ),
					'status'       => (string) ( $action['status'] ?? '' ),
					'status_label' => (string) ( $action['status_label'] ?? '' ),
					'description'  => (string) ( $action['description'] ?? '' ),
					'extension'    => ! empty( $action['extension'] ),
					'fallback'     => ! empty( $action['fallback'] ),
					'block_name'   => isset( $action['block_name'] ) ? (string) $action['block_name'] : null,
				);
			}
			$groups[] = array(
				'id'          => (string) ( $group['id'] ?? '' ),
				'title'       => (string) ( $group['title'] ?? $group['label'] ?? '' ),
				'description' => (string) ( $group['description'] ?? '' ),
				'actions'     => $actions,
			);
		}

		return array(
			'available' => ! empty( $picker['available'] ),
			'reason'    => (string) ( $picker['reason'] ?? '' ),
			'groups'    => $groups,
		);
	}

	/**
	 * Integration ownership rows (e.g. Woo Product).
	 *
	 * @param array $picker Picker.
	 * @return array<int,array<string,mixed>>
	 */
	private static function collect_integration_ownership( array $picker ) {
		$out = array();
		foreach ( (array) ( $picker['groups'] ?? array() ) as $group ) {
			foreach ( (array) ( $group['actions'] ?? array() ) as $action ) {
				if ( ! is_array( $action ) ) {
					continue;
				}
				if ( 'integration_owned' !== (string) ( $action['action'] ?? '' ) ) {
					continue;
				}
				$out[] = array(
					'type'         => (string) ( $action['type'] ?? '' ),
					'status'       => 'integration_owned',
					'status_label' => (string) ( $action['status_label'] ?? '' ),
					'description'  => (string) ( $action['description'] ?? '' ),
					'owner'        => 'woocommerce',
				);
			}
		}
		return $out;
	}

	/**
	 * Recursively sanitize public strings (strip tags; no PHP class/callback leakage).
	 *
	 * @param mixed $value Value.
	 * @return mixed
	 */
	public static function sanitize_public_payload( $value ) {
		if ( is_string( $value ) ) {
			$value = wp_check_invalid_utf8( $value );
			$value = wp_strip_all_tags( $value );
			// Preserve newlines in help/descriptions; collapse only control chars via textarea sanitizer.
			if ( false !== strpos( $value, "\n" ) ) {
				return sanitize_textarea_field( $value );
			}
			return sanitize_text_field( $value );
		}
		if ( is_array( $value ) ) {
			$out = array();
			foreach ( $value as $key => $item ) {
				if ( is_string( $key ) && ( false !== strpos( $key, '\\' ) || false !== strpos( $key, '::' ) ) ) {
					continue;
				}
				if ( is_object( $item ) ) {
					continue;
				}
				$out[ $key ] = self::sanitize_public_payload( $item );
			}
			return $out;
		}
		if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) {
			return $value;
		}
		return null;
	}

	/**
	 * Compact field-policy fixture row for a type (tests / cross-system).
	 *
	 * @param string $type Schema type.
	 * @return array<string,mixed>|null
	 */
	public static function field_policy_fixture( $type ) {
		$built = SchemaGuidedFormSchema::build_for_type( $type );
		if ( empty( $built['ok'] ) ) {
			return null;
		}
		$fields = array();
		foreach ( (array) ( $built['fields'] ?? array() ) as $field ) {
			if ( ! is_array( $field ) || empty( $field['id'] ) ) {
				continue;
			}
			$row = array(
				'id'       => (string) $field['id'],
				'control'  => (string) ( $field['control'] ?? '' ),
				'required' => ! empty( $field['required'] ),
			);
			if ( isset( $field['default'] ) && is_scalar( $field['default'] ) ) {
				$row['default'] = $field['default'];
			}
			$fields[] = $row;
		}
		return array(
			'contractVersion'        => self::CONTRACT_VERSION,
			'formSchemaVersion'      => self::FORM_SCHEMA_VERSION,
			'readinessPolicyVersion' => SchemaReadinessEvaluator::POLICY_VERSION,
			'type'                   => $type,
			'label'                  => (string) ( $built['label'] ?? $type ),
			'requiresVisibility'     => ! empty( $built['requires_visibility'] ),
			'clientValidityNote'     => 'advisory_only',
			'fields'                 => $fields,
		);
	}
}
