<?php
/**
 * Custom JSON-LD modes, sanitization, and conflict helpers.
 *
 * Modes:
 * - add: merge sanitized custom entities with automatic output
 * - replace: intentional full-graph override (requires confirmation for new saves)
 * - invalid_withheld: malformed input kept for editor, never blanks foundation
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

use SEOvaultSEO\Core\PostMeta;
use SEOvaultSEO\Schema\Validation\JsonSyntaxValidator;
use SEOvaultSEO\Schema\Validation\ValidationLimits;
use SEOvaultSEO\Schema\Validation\ValueInspector;
use SEOvaultSEO\Utils\Sanitizer;

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

/**
 * Versioned custom JSON mode metadata and parse/save helpers.
 */
class SchemaCustomJson {

	/**
	 * Versioned metadata document key.
	 */
	const META_DOCUMENT = '_seovault_schema_json_meta';

	/**
	 * Last known valid custom JSON (for restore / replace continuity).
	 */
	const META_LAST_VALID = '_seovault_schema_json_last_valid';

	/**
	 * Metadata schema version.
	 */
	const META_VERSION = 1;

	const MODE_ADD              = 'add';
	const MODE_REPLACE          = 'replace';
	const MODE_INVALID_WITHHELD = 'invalid_withheld';

	/**
	 * Allowed emission modes (excludes withheld).
	 *
	 * @var string[]
	 */
	const EMISSION_MODES = array( self::MODE_ADD, self::MODE_REPLACE );

	/**
	 * Resolve stored mode string for a post.
	 *
	 * @param int $post_id Post ID.
	 * @return string
	 */
	public static function get_mode( $post_id ) {
		return SchemaPostMigration::get_json_mode( $post_id );
	}

	/**
	 * Persist mode meta.
	 *
	 * @param int    $post_id Post ID.
	 * @param string $mode    Mode.
	 * @return void
	 */
	public static function set_mode( $post_id, $mode ) {
		$post_id = (int) $post_id;
		$mode    = self::sanitize_mode( $mode );
		if ( $post_id <= 0 || '' === $mode ) {
			return;
		}
		update_post_meta( $post_id, SchemaPostMigration::META_JSON_MODE, $mode );
	}

	/**
	 * Sanitize a mode string.
	 *
	 * @param mixed $mode Raw mode.
	 * @return string
	 */
	public static function sanitize_mode( $mode ) {
		$mode = is_string( $mode ) ? sanitize_key( $mode ) : '';
		$allowed = array( self::MODE_ADD, self::MODE_REPLACE, self::MODE_INVALID_WITHHELD );
		return in_array( $mode, $allowed, true ) ? $mode : '';
	}

	/**
	 * Load versioned metadata document.
	 *
	 * @param int $post_id Post ID.
	 * @return array<string,mixed>
	 */
	public static function get_meta_document( $post_id ) {
		$raw = get_post_meta( (int) $post_id, self::META_DOCUMENT, true );
		if ( ! is_array( $raw ) ) {
			return self::default_meta_document();
		}
		return array_merge( self::default_meta_document(), $raw );
	}

	/**
	 * Default versioned metadata.
	 *
	 * @return array<string,mixed>
	 */
	public static function default_meta_document() {
		return array(
			'version'           => self::META_VERSION,
			'mode'              => '',
			'replace_confirmed' => false,
			'confirmed_at'      => 0,
			'updated_at'        => 0,
			'fingerprint'       => '',
			'legacy'            => false,
		);
	}

	/**
	 * Persist versioned metadata (clamped to known keys).
	 *
	 * @param int   $post_id Post ID.
	 * @param array $meta    Meta patch.
	 * @return void
	 */
	public static function update_meta_document( $post_id, array $meta ) {
		$post_id  = (int) $post_id;
		$current  = self::get_meta_document( $post_id );
		$merged   = array_merge( $current, $meta );
		$document = self::default_meta_document();
		$document['version']           = self::META_VERSION;
		$document['mode']              = self::sanitize_mode( $merged['mode'] ?? '' );
		$document['replace_confirmed'] = ! empty( $merged['replace_confirmed'] );
		$document['confirmed_at']      = isset( $merged['confirmed_at'] ) ? (int) $merged['confirmed_at'] : 0;
		$document['updated_at']        = isset( $merged['updated_at'] ) ? (int) $merged['updated_at'] : time();
		$document['fingerprint']       = isset( $merged['fingerprint'] ) ? sanitize_text_field( (string) $merged['fingerprint'] ) : '';
		$document['legacy']            = ! empty( $merged['legacy'] );
		update_post_meta( $post_id, self::META_DOCUMENT, $document );
	}

	/**
	 * Whether replace mode is allowed to blank automatic entities.
	 *
	 * Legacy migrated replace is treated as already confirmed.
	 * Unmigrated posts with valid custom JSON and empty mode keep legacy replace.
	 *
	 * @param int $post_id Post ID.
	 * @return bool
	 */
	public static function is_replace_active( $post_id ) {
		$decision = self::resolve_emission( $post_id, PostMeta::get_schema_json( $post_id ) );
		return 'replace' === ( $decision['action'] ?? '' );
	}

	/**
	 * Resolve how custom JSON should affect graph emission.
	 *
	 * @param int    $post_id     Post ID.
	 * @param string $custom_json Raw custom JSON (optional override).
	 * @return array{
	 *   action:string,
	 *   mode:string,
	 *   valid:bool,
	 *   present:bool,
	 *   data:array,
	 *   graph:array,
	 *   issues:array,
	 *   replace_confirmed:bool,
	 *   reason:string
	 * }
	 */
	public static function resolve_emission( $post_id, $custom_json = null ) {
		$post_id = (int) $post_id;
		if ( null === $custom_json ) {
			$custom_json = PostMeta::get_schema_json( $post_id );
		}
		$custom_json = is_string( $custom_json ) ? $custom_json : '';
		$present     = '' !== trim( $custom_json );
		$stored_mode = self::get_mode( $post_id );
		$meta_doc    = self::get_meta_document( $post_id );
		$confirmed   = ! empty( $meta_doc['replace_confirmed'] ) || ! empty( $meta_doc['legacy'] );

		$empty = array(
			'action'            => 'none',
			'mode'              => $stored_mode,
			'valid'             => false,
			'present'           => $present,
			'data'              => array(),
			'graph'             => array(),
			'issues'            => array(),
			'replace_confirmed' => $confirmed,
			'reason'            => '',
		);

		if ( ! $present ) {
			if ( self::MODE_INVALID_WITHHELD === $stored_mode ) {
				$empty['action'] = 'withhold';
				$empty['mode']   = self::MODE_INVALID_WITHHELD;
				$empty['reason'] = 'invalid_withheld';
			}
			return $empty;
		}

		$parsed = self::parse( $custom_json );
		if ( empty( $parsed['is_valid'] ) ) {
			return array_merge(
				$empty,
				array(
					'action' => 'withhold',
					'mode'   => self::MODE_INVALID_WITHHELD === $stored_mode ? self::MODE_INVALID_WITHHELD : $stored_mode,
					'valid'  => false,
					'issues' => $parsed['issues'],
					'reason' => 'invalid_syntax',
				)
			);
		}

		$mode = $stored_mode;
		if ( '' === $mode ) {
			// Legacy unmigrated custom JSON retains replace semantics.
			if ( ! SchemaPostMigration::is_post_migrated( $post_id ) ) {
				$mode      = self::MODE_REPLACE;
				$confirmed = true;
			} else {
				$mode = self::MODE_ADD;
			}
		}

		$graph = self::nodes_to_graph( $parsed['data'] );
		if ( empty( $graph['@graph'] ) ) {
			return array_merge(
				$empty,
				array(
					'action' => 'withhold',
					'mode'   => $mode,
					'valid'  => true,
					'data'   => $parsed['data'],
					'reason' => 'empty_graph',
				)
			);
		}

		if ( self::MODE_REPLACE === $mode ) {
			if ( $confirmed ) {
				return array(
					'action'            => 'replace',
					'mode'              => self::MODE_REPLACE,
					'valid'             => true,
					'present'           => true,
					'data'              => $parsed['data'],
					'graph'             => $graph,
					'issues'            => array(),
					'replace_confirmed' => true,
					'reason'            => ! empty( $meta_doc['legacy'] ) || ( '' === $stored_mode && ! SchemaPostMigration::is_post_migrated( $post_id ) )
						? 'legacy_replace'
						: 'replace_confirmed',
				);
			}

			return array(
				'action'            => 'needs_confirmation',
				'mode'              => self::MODE_REPLACE,
				'valid'             => true,
				'present'           => true,
				'data'              => $parsed['data'],
				'graph'             => $graph,
				'issues'            => array(),
				'replace_confirmed' => false,
				'reason'            => 'replace_unconfirmed',
			);
		}

		// Default / explicit add.
		return array(
			'action'            => 'add',
			'mode'              => self::MODE_ADD,
			'valid'             => true,
			'present'           => true,
			'data'              => $parsed['data'],
			'graph'             => $graph,
			'issues'            => array(),
			'replace_confirmed' => false,
			'reason'            => 'add',
		);
	}

	/**
	 * Parse and bound-check custom JSON.
	 *
	 * Rejects syntax errors, scalar roots, oversized payloads, and excessive depth.
	 *
	 * @param mixed $json    Raw JSON.
	 * @param array $limits  Optional limit overrides.
	 * @return array{is_valid:bool,data:array,issues:array<int,array>}
	 */
	public static function parse( $json, $limits = array() ) {
		$limits    = ValidationLimits::get( $limits );
		$validator = new JsonSyntaxValidator();
		$parsed    = $validator->parse( $json, $limits );

		if ( empty( $parsed['is_valid'] ) ) {
			$issues = array();
			foreach ( $parsed['issues'] as $issue ) {
				if ( is_object( $issue ) && method_exists( $issue, 'to_array' ) ) {
					$issues[] = $issue->to_array();
				} elseif ( is_array( $issue ) ) {
					$issues[] = $issue;
				} elseif ( is_object( $issue ) && method_exists( $issue, 'get_code' ) ) {
					$issues[] = array( 'code' => $issue->get_code() );
				}
			}
			return array(
				'is_valid' => false,
				'data'     => array(),
				'issues'   => $issues,
			);
		}

		$data = $parsed['data'];
		if ( self::depth_exceeds( $data, (int) $limits['max_depth'] ) ) {
			return array(
				'is_valid' => false,
				'data'     => array(),
				'issues'   => array(
					array(
						'code'     => 'json.depth.exceeded',
						'severity' => 'error',
					),
				),
			);
		}

		$entity_count = self::count_entities( $data );
		if ( $entity_count > (int) $limits['max_entities'] ) {
			return array(
				'is_valid' => false,
				'data'     => array(),
				'issues'   => array(
					array(
						'code'     => 'json.entities.exceeded',
						'severity' => 'error',
					),
				),
			);
		}

		return array(
			'is_valid' => true,
			'data'     => $data,
			'issues'   => array(),
		);
	}

	/**
	 * Normalize parsed data into a @context/@graph envelope.
	 *
	 * @param array $data Parsed JSON-LD.
	 * @return array
	 */
	public static function nodes_to_graph( array $data ) {
		$context = 'https://schema.org';
		$nodes   = array();

		if ( ValueInspector::is_list( $data ) ) {
			$nodes = $data;
		} elseif ( isset( $data['@graph'] ) && is_array( $data['@graph'] ) ) {
			$context = isset( $data['@context'] ) ? $data['@context'] : $context;
			$nodes   = $data['@graph'];
		} else {
			if ( isset( $data['@context'] ) ) {
				$context = $data['@context'];
				unset( $data['@context'] );
			}
			if ( ! empty( $data ) ) {
				$nodes[] = $data;
			}
		}

		$nodes = array_values(
			array_filter(
				$nodes,
				static function ( $node ) {
					return is_array( $node ) && ! empty( $node ) && ! ValueInspector::is_list( $node );
				}
			)
		);

		if ( empty( $nodes ) ) {
			return array();
		}

		return array(
			'@context' => $context,
			'@graph'   => $nodes,
		);
	}

	/**
	 * Save custom JSON from the editor with mode defaults and confirmation.
	 *
	 * New valid saves default to Add. Replace requires an explicit confirmation flag.
	 * Invalid input is backed up and withheld; a previously stored last-valid value is kept.
	 *
	 * @param int    $post_id            Post ID.
	 * @param string $raw_json           Raw editor value.
	 * @param string $requested_mode     Requested mode (add|replace); empty = default.
	 * @param bool   $replace_confirmed  Explicit replace confirmation.
	 * @return array{ok:bool,mode:string,action:string,message:string}
	 */
	public static function save( $post_id, $raw_json, $requested_mode = '', $replace_confirmed = false ) {
		$post_id  = (int) $post_id;
		$raw_json = is_string( $raw_json ) ? $raw_json : '';
		$limits   = ValidationLimits::get();

		if ( $post_id <= 0 ) {
			return array(
				'ok'      => false,
				'mode'    => '',
				'action'  => 'error',
				'message' => 'invalid_post',
			);
		}

		if ( '' === trim( $raw_json ) ) {
			self::clear( $post_id );
			return array(
				'ok'      => true,
				'mode'    => '',
				'action'  => 'cleared',
				'message' => 'cleared',
			);
		}

		if ( strlen( $raw_json ) > (int) $limits['max_json_bytes'] ) {
			self::withhold_invalid( $post_id, $raw_json );
			return array(
				'ok'      => false,
				'mode'    => self::MODE_INVALID_WITHHELD,
				'action'  => 'withheld',
				'message' => 'too_large',
			);
		}

		$parsed = self::parse( $raw_json, $limits );
		if ( empty( $parsed['is_valid'] ) ) {
			self::withhold_invalid( $post_id, $raw_json );
			return array(
				'ok'      => false,
				'mode'    => self::MODE_INVALID_WITHHELD,
				'action'  => 'withheld',
				'message' => 'invalid',
			);
		}

		$mode = self::sanitize_mode( $requested_mode );
		if ( '' === $mode || self::MODE_INVALID_WITHHELD === $mode ) {
			$mode = self::MODE_ADD;
		}

		$confirmed = false;
		if ( self::MODE_REPLACE === $mode ) {
			$confirmed = (bool) $replace_confirmed;
			if ( ! $confirmed ) {
				// Persist as replace intent but do not activate until confirmed.
				$mode = self::MODE_REPLACE;
			}
		}

		$normalized = Sanitizer::sanitize_schema_json( $raw_json );
		if ( '' === $normalized ) {
			$normalized = wp_json_encode( $parsed['data'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		}

		PostMeta::set_schema_json( $post_id, is_string( $normalized ) ? $normalized : $raw_json );
		update_post_meta( $post_id, self::META_LAST_VALID, is_string( $normalized ) ? $normalized : $raw_json );
		delete_post_meta( $post_id, SchemaPostMigration::META_JSON_INVALID_BACKUP );
		self::set_mode( $post_id, $mode );
		self::update_meta_document(
			$post_id,
			array(
				'mode'              => $mode,
				'replace_confirmed' => $confirmed,
				'confirmed_at'      => $confirmed ? time() : 0,
				'updated_at'        => time(),
				'fingerprint'       => 'sha256:' . hash( 'sha256', (string) $normalized ),
				'legacy'            => false,
			)
		);

		return array(
			'ok'      => true,
			'mode'    => $mode,
			'action'  => ( self::MODE_REPLACE === $mode && ! $confirmed ) ? 'needs_confirmation' : $mode,
			'message' => 'saved',
		);
	}

	/**
	 * Mark legacy migrated valid custom JSON as replace + confirmed.
	 *
	 * @param int $post_id Post ID.
	 * @return void
	 */
	public static function mark_legacy_replace( $post_id ) {
		$post_id = (int) $post_id;
		self::set_mode( $post_id, self::MODE_REPLACE );
		$json = PostMeta::get_schema_json( $post_id );
		if ( '' !== trim( (string) $json ) ) {
			update_post_meta( $post_id, self::META_LAST_VALID, $json );
		}
		self::update_meta_document(
			$post_id,
			array(
				'mode'              => self::MODE_REPLACE,
				'replace_confirmed' => true,
				'confirmed_at'      => time(),
				'updated_at'        => time(),
				'fingerprint'       => '' !== trim( (string) $json ) ? 'sha256:' . hash( 'sha256', (string) $json ) : '',
				'legacy'            => true,
			)
		);
		delete_post_meta( $post_id, SchemaPostMigration::META_JSON_INVALID_BACKUP );
	}

	/**
	 * Withhold invalid custom JSON without suppressing foundation.
	 *
	 * Keeps last-valid for later restore. Stores raw invalid for the editor.
	 *
	 * @param int    $post_id Post ID.
	 * @param string $raw     Raw invalid JSON.
	 * @return void
	 */
	public static function withhold_invalid( $post_id, $raw ) {
		$post_id = (int) $post_id;
		$raw     = (string) $raw;
		update_post_meta( $post_id, SchemaPostMigration::META_JSON_INVALID_BACKUP, $raw );
		self::set_mode( $post_id, self::MODE_INVALID_WITHHELD );
		// Keep editor draft in live meta so authors can fix typos; emission withholds it.
		PostMeta::set_schema_json( $post_id, $raw );
		self::update_meta_document(
			$post_id,
			array(
				'mode'              => self::MODE_INVALID_WITHHELD,
				'replace_confirmed' => false,
				'confirmed_at'      => 0,
				'updated_at'        => time(),
				'fingerprint'       => 'sha256:' . hash( 'sha256', $raw ),
				'legacy'            => false,
			)
		);
	}

	/**
	 * Clear custom JSON and mode metadata.
	 *
	 * @param int $post_id Post ID.
	 * @return void
	 */
	public static function clear( $post_id ) {
		$post_id = (int) $post_id;
		PostMeta::delete_schema_json( $post_id );
		delete_post_meta( $post_id, SchemaPostMigration::META_JSON_MODE );
		delete_post_meta( $post_id, SchemaPostMigration::META_JSON_INVALID_BACKUP );
		delete_post_meta( $post_id, self::META_DOCUMENT );
		delete_post_meta( $post_id, self::META_LAST_VALID );
	}

	/**
	 * Last known valid custom JSON string, if any.
	 *
	 * @param int $post_id Post ID.
	 * @return string
	 */
	public static function get_last_valid( $post_id ) {
		$raw = get_post_meta( (int) $post_id, self::META_LAST_VALID, true );
		return is_string( $raw ) ? $raw : '';
	}

	/**
	 * Whether a distinct last-valid backup can be restored into the editor.
	 *
	 * Restoring is allowed when last-valid exists and the live editor value is
	 * empty, invalid, or different from the last-valid payload.
	 *
	 * @param int $post_id Post ID.
	 * @return bool
	 */
	public static function can_restore_last_valid( $post_id ) {
		$last = self::get_last_valid( $post_id );
		if ( '' === trim( $last ) ) {
			return false;
		}
		$parsed = self::parse( $last );
		if ( empty( $parsed['is_valid'] ) ) {
			return false;
		}
		$current = (string) PostMeta::get_schema_json( $post_id );
		if ( '' === trim( $current ) ) {
			return true;
		}
		if ( hash( 'sha256', $current ) === hash( 'sha256', $last ) ) {
			$mode = self::get_mode( $post_id );
			return self::MODE_INVALID_WITHHELD === $mode;
		}
		return true;
	}

	/**
	 * Restore the last valid custom JSON into live meta (Add mode by default).
	 *
	 * Preserves a previous Add/Replace mode from the meta document when still
	 * meaningful; Replace restores without re-confirming only when the document
	 * already had replace_confirmed or legacy.
	 *
	 * @param int $post_id Post ID.
	 * @return array{ok:bool,mode:string,action:string,message:string,json:string}
	 */
	public static function restore_last_valid( $post_id ) {
		$post_id = (int) $post_id;
		$last    = self::get_last_valid( $post_id );
		if ( '' === trim( $last ) || ! self::can_restore_last_valid( $post_id ) ) {
			return array(
				'ok'      => false,
				'mode'    => '',
				'action'  => 'error',
				'message' => 'no_last_valid',
				'json'    => '',
			);
		}

		$meta = self::get_meta_document( $post_id );
		$mode = self::sanitize_mode( $meta['mode'] ?? '' );
		if ( '' === $mode || self::MODE_INVALID_WITHHELD === $mode ) {
			$mode = self::MODE_ADD;
		}
		$confirmed = self::MODE_REPLACE === $mode
			&& ( ! empty( $meta['replace_confirmed'] ) || ! empty( $meta['legacy'] ) );

		$result = self::save( $post_id, $last, $mode, $confirmed );
		$result['json'] = (string) PostMeta::get_schema_json( $post_id );
		if ( empty( $result['ok'] ) ) {
			$result['message'] = 'restore_failed';
		} else {
			$result['message'] = 'restored';
			$result['action']  = 'restored';
		}
		return $result;
	}

	/**
	 * Pretty-print valid custom JSON using server parse bounds.
	 *
	 * @param string $raw Raw JSON.
	 * @return array{ok:bool,json:string,issues:array}
	 */
	public static function format( $raw ) {
		$raw    = is_string( $raw ) ? $raw : '';
		$parsed = self::parse( $raw );
		if ( empty( $parsed['is_valid'] ) ) {
			return array(
				'ok'     => false,
				'json'   => $raw,
				'issues' => isset( $parsed['issues'] ) && is_array( $parsed['issues'] ) ? $parsed['issues'] : array(),
			);
		}
		$encoded = wp_json_encode(
			$parsed['data'],
			JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
		);
		return array(
			'ok'     => is_string( $encoded ),
			'json'   => is_string( $encoded ) ? $encoded : $raw,
			'issues' => array(),
		);
	}

	/**
	 * Author-facing UX state for the Advanced JSON editor / Review surface.
	 *
	 * Distinguishes empty, valid emitted, replace-pending, invalid saved, and
	 * whether a last-valid restore is available. Unsaved editor dirtiness is a
	 * client-side overlay on top of this saved state.
	 *
	 * @param int         $post_id     Post ID.
	 * @param string|null $editor_json Optional editor override (preview).
	 * @return array{
	 *   state:string,
	 *   label:string,
	 *   summary:string,
	 *   mode:string,
	 *   action:string,
	 *   present:bool,
	 *   valid:bool,
	 *   emitted:bool,
	 *   can_restore:bool,
	 *   published_graph:string
	 * }
	 */
	public static function describe_ux_state( $post_id, $editor_json = null ) {
		$post_id  = (int) $post_id;
		$decision = self::resolve_emission( $post_id, $editor_json );
		$action   = (string) ( $decision['action'] ?? 'none' );
		$mode     = (string) ( $decision['mode'] ?? '' );
		$present  = ! empty( $decision['present'] );
		$valid    = ! empty( $decision['valid'] );
		$emitted  = in_array( $action, array( 'add', 'replace' ), true );
		$can_restore = null === $editor_json ? self::can_restore_last_valid( $post_id ) : false;

		$state = 'empty';
		if ( $present && ! $valid ) {
			$state = 'invalid_saved';
		} elseif ( 'needs_confirmation' === $action ) {
			$state = 'replace_unconfirmed';
		} elseif ( 'withhold' === $action && self::MODE_INVALID_WITHHELD === $mode ) {
			$state = 'invalid_saved';
		} elseif ( 'add' === $action ) {
			$state = 'emitted_add';
		} elseif ( 'replace' === $action ) {
			$state = 'emitted_replace';
		} elseif ( $present && $valid ) {
			$state = 'valid_not_emitted';
		}

		$labels = array(
			'empty'               => __( 'No custom JSON', 'seovault-ai' ),
			'emitted_add'         => __( 'Saved — Add mode', 'seovault-ai' ),
			'emitted_replace'     => __( 'Saved — Replace mode', 'seovault-ai' ),
			'replace_unconfirmed' => __( 'Replace not confirmed', 'seovault-ai' ),
			'invalid_saved'       => __( 'Invalid saved JSON', 'seovault-ai' ),
			'valid_not_emitted'   => __( 'Custom JSON not publishing', 'seovault-ai' ),
		);

		$summaries = array(
			'empty'               => __( 'Automatic schema publishes for this page. Custom JSON is optional.', 'seovault-ai' ),
			'emitted_add'         => __( 'This custom JSON merges with automatic schema in the published graph.', 'seovault-ai' ),
			'emitted_replace'     => __( 'This custom JSON replaces automatic schema entities on this page.', 'seovault-ai' ),
			'replace_unconfirmed' => __( 'Replace is selected but not confirmed. Automatic schema still publishes; custom JSON is withheld.', 'seovault-ai' ),
			'invalid_saved'       => __( 'The saved custom JSON is invalid and withheld. The published graph continues without it.', 'seovault-ai' ),
			'valid_not_emitted'   => __( 'Custom JSON is present but not part of the published graph.', 'seovault-ai' ),
		);

		$published = 'automatic';
		if ( 'replace' === $action ) {
			$published = 'custom_replace';
		} elseif ( 'add' === $action ) {
			$published = 'automatic_plus_custom';
		}

		return array(
			'state'           => $state,
			'label'           => isset( $labels[ $state ] ) ? $labels[ $state ] : $labels['empty'],
			'summary'         => isset( $summaries[ $state ] ) ? $summaries[ $state ] : $summaries['empty'],
			'mode'            => $mode,
			'action'          => $action,
			'present'         => $present,
			'valid'           => $valid,
			'emitted'         => $emitted,
			'can_restore'     => $can_restore,
			'published_graph' => $published,
		);
	}

	/**
	 * Entities that automatic output would lose under Replace mode.
	 *
	 * @param array $automatic_graph Automatic graph (@graph envelope or nodes).
	 * @param array $custom_graph    Custom replace graph.
	 * @return array<int,array{type:string,entity_id:string}>
	 */
	public static function displaced_by_replace( array $automatic_graph, array $custom_graph ) {
		$auto_nodes = array();
		if ( isset( $automatic_graph['@graph'] ) && is_array( $automatic_graph['@graph'] ) ) {
			$auto_nodes = $automatic_graph['@graph'];
		} elseif ( ValueInspector::is_list( $automatic_graph ) ) {
			$auto_nodes = $automatic_graph;
		}

		$displaced = array();
		foreach ( $auto_nodes as $node ) {
			if ( ! is_array( $node ) || empty( $node ) ) {
				continue;
			}
			$id   = isset( $node['@id'] ) && is_string( $node['@id'] ) ? $node['@id'] : '';
			$type = '';
			if ( isset( $node['@type'] ) ) {
				$types = ValueInspector::get_types( $node );
				$type  = ! empty( $types ) ? (string) $types[0] : '';
			}
			// Entire automatic graph disappears under replace; list each entity.
			$displaced[] = array(
				'type'      => $type,
				'entity_id' => $id,
			);
		}

		return $displaced;
	}

	/**
	 * Build a tagged contribution for Add-mode merge.
	 *
	 * @param int   $post_id Post ID.
	 * @param array $data    Parsed custom JSON data.
	 * @return array
	 */
	public static function tagged_contribution( $post_id, array $data ) {
		return array(
			'source'     => 'custom',
			'source_ref' => 'post:' . (int) $post_id . ':schema_json',
			'schema'     => $data,
		);
	}

	/**
	 * Whether nesting depth exceeds the limit.
	 *
	 * @param mixed $value Value.
	 * @param int   $max   Max depth.
	 * @param int   $depth Current depth.
	 * @return bool
	 */
	private static function depth_exceeds( $value, $max, $depth = 0 ) {
		if ( $depth > $max ) {
			return true;
		}
		if ( ! is_array( $value ) ) {
			return false;
		}
		foreach ( $value as $child ) {
			if ( self::depth_exceeds( $child, $max, $depth + 1 ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Count top-level entities in custom JSON.
	 *
	 * @param array $data Parsed data.
	 * @return int
	 */
	private static function count_entities( array $data ) {
		if ( ValueInspector::is_list( $data ) ) {
			return count( $data );
		}
		if ( isset( $data['@graph'] ) && is_array( $data['@graph'] ) ) {
			return count( $data['@graph'] );
		}
		return 1;
	}
}
