<?php
/**
 * FAQ connector contract helpers.
 *
 * Versioned handshake fields, post-save schema summary, and optional
 * stale/idempotent guards for connected publishing.
 *
 * @package SEOvaultSEO\Api
 */

namespace SEOvaultSEO\Api;

use SEOvaultSEO\Blocks\FaqBlockAttributeSanitizer;
use SEOvaultSEO\Blocks\HowToBlockAttributeSanitizer;
use SEOvaultSEO\Schema\SchemaManager;

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

/**
 * Canonical FAQ / schema connector contract for the web app.
 */
class FaqConnectorContract {

	/**
	 * Contract version advertised on verify + post write responses.
	 * Bump when the FAQ publish/summary contract changes incompatibly.
	 */
	const CONTRACT_VERSION = 1;

	/**
	 * FAQ Gutenberg attribute schema version mirrored from the block sanitizer.
	 */
	const FAQ_BLOCK_SCHEMA_VERSION = 1;

	/**
	 * Idempotency cache TTL (seconds).
	 */
	const IDEMPOTENCY_TTL = 600;

	/**
	 * Public contract metadata for verify / capability handshake.
	 *
	 * @return array<string,mixed>
	 */
	public static function handshake() {
		return array(
			'connector_contract_version'     => self::CONTRACT_VERSION,
			'faq_block_schema_version'       => (int) FaqBlockAttributeSanitizer::SCHEMA_VERSION,
			'faq_native_block_publish'       => true,
			'howto_block_schema_version'     => (int) HowToBlockAttributeSanitizer::SCHEMA_VERSION,
			'howto_native_block_publish'     => true,
			'quick_answer_native_block'      => true,
			'quick_answer_block_schema_version' => 1,
			'aeo_quick_answer_block_inject'  => true,
			'schema_summary_on_save'         => true,
			'content_hash_guard'             => true,
			'idempotency_guard'              => true,
		);
	}

	/**
	 * Build a compact canonical schema summary after save (not full JSON-LD dump).
	 *
	 * @param int|\WP_Post $post Post.
	 * @return array<string,mixed>
	 */
	public static function build_schema_summary( $post ) {
		$post_obj = get_post( $post );
		if ( ! $post_obj ) {
			return array(
				'contract_version' => self::CONTRACT_VERSION,
				'ok'               => false,
				'error'            => 'invalid_post',
			);
		}

		$result      = SchemaManager::get_instance()->build_result( $post_obj );
		$graph       = $result->get_graph();
		$emitted     = $result->get_emitted();
		$source_map  = $result->get_source_map();
		$conflicts   = $result->get_conflicts();
		$improvements = $result->get_improvements();

		$emitted_types = array();
		foreach ( $emitted as $descriptor ) {
			$type = isset( $descriptor['type'] ) ? (string) $descriptor['type'] : '';
			if ( '' !== $type ) {
				$emitted_types[] = $type;
			}
		}
		$emitted_types = array_values( array_unique( $emitted_types ) );
		sort( $emitted_types );

		$faq_nodes = self::find_nodes_by_type( $graph, 'FAQPage' );
		$faq_pairs = array();
		if ( ! empty( $faq_nodes[0]['mainEntity'] ) && is_array( $faq_nodes[0]['mainEntity'] ) ) {
			foreach ( $faq_nodes[0]['mainEntity'] as $entity ) {
				if ( ! is_array( $entity ) ) {
					continue;
				}
				$question = isset( $entity['name'] ) ? sanitize_text_field( (string) $entity['name'] ) : '';
				$answer   = '';
				if ( isset( $entity['acceptedAnswer'] ) && is_array( $entity['acceptedAnswer'] ) ) {
					$answer = FaqBlockAttributeSanitizer::normalize_plain_answer(
						isset( $entity['acceptedAnswer']['text'] ) ? $entity['acceptedAnswer']['text'] : ''
					);
				}
				if ( '' === $question || '' === $answer ) {
					continue;
				}
				$faq_pairs[] = array(
					'question' => $question,
					'answer'   => $answer,
				);
			}
		}

		$faq_source = self::resolve_faq_source( $emitted, $source_map );
		$dup_codes  = array();
		foreach ( $improvements as $item ) {
			$code = is_array( $item ) && isset( $item['code'] ) ? (string) $item['code'] : '';
			if ( 'faq.block.duplicate_question' === $code ) {
				$dup_codes[] = $code;
			}
		}

		$howto_nodes = self::find_nodes_by_type( $graph, 'HowTo' );
		$howto_names = array();
		$howto_step_count = 0;
		foreach ( $howto_nodes as $howto_node ) {
			if ( ! is_array( $howto_node ) ) {
				continue;
			}
			$name = isset( $howto_node['name'] ) ? sanitize_text_field( (string) $howto_node['name'] ) : '';
			if ( '' !== $name ) {
				$howto_names[] = $name;
			}
			if ( ! empty( $howto_node['step'] ) && is_array( $howto_node['step'] ) ) {
				foreach ( $howto_node['step'] as $step ) {
					if ( ! is_array( $step ) ) {
						continue;
					}
					$step_name = isset( $step['name'] ) ? sanitize_text_field( (string) $step['name'] ) : '';
					$step_text = isset( $step['text'] ) ? HowToBlockAttributeSanitizer::normalize_plain_text( (string) $step['text'] ) : '';
					if ( '' !== $step_name || '' !== $step_text ) {
						++$howto_step_count;
					}
				}
			}
		}
		$howto_source = self::resolve_howto_source( $emitted, $source_map );

		return array(
			'contract_version' => self::CONTRACT_VERSION,
			'ok'               => true,
			'post_id'          => (int) $post_obj->ID,
			'graph_hash'       => $result->get_graph_hash(),
			'emitted_types'    => $emitted_types,
			'withheld_count'   => count( $result->get_withheld() ),
			'conflict_count'   => count( $conflicts ),
			'faq'              => array(
				'emitted'             => ! empty( $faq_nodes ),
				'count'               => count( $faq_nodes ),
				'source'              => $faq_source,
				'question_count'      => count( $faq_pairs ),
				'questions'           => $faq_pairs,
				'duplicate_detected'  => ! empty( $dup_codes ) || count( $faq_nodes ) > 1,
				'visible_matches_ld'  => true,
			),
			'howto'            => array(
				'emitted'     => ! empty( $howto_nodes ),
				'count'       => count( $howto_nodes ),
				'source'      => $howto_source,
				'step_count'  => $howto_step_count,
				'names'       => $howto_names,
			),
			'content_hash'     => hash( 'sha256', (string) $post_obj->post_content ),
		);
	}

	/**
	 * Optional stale content_hash guard for post updates.
	 * Missing/empty content_hash preserves backward compatibility.
	 *
	 * @param int                 $post_id Post ID.
	 * @param array<string,mixed> $payload Request JSON.
	 * @return true|\WP_Error
	 */
	public static function assert_fresh_content( $post_id, array $payload ) {
		if ( ! isset( $payload['content_hash'] ) || '' === (string) $payload['content_hash'] ) {
			return true;
		}

		$post = get_post( $post_id );
		if ( ! $post ) {
			return new \WP_Error( 'not_found', 'Post not found.', array( 'status' => 404 ) );
		}

		$current  = hash( 'sha256', (string) $post->post_content );
		$provided = strtolower( (string) $payload['content_hash'] );
		if ( ! hash_equals( $current, $provided ) ) {
			return new \WP_Error(
				'stale_content',
				'This post changed since you loaded it. Reload and try again.',
				array(
					'status' => 409,
					'data'   => array(
						'code'         => 'stale_content',
						'content_hash' => $current,
						'post_id'      => (int) $post_id,
					),
				)
			);
		}

		return true;
	}

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

	/**
	 * Read cached idempotent update response.
	 *
	 * @param int    $post_id Post ID.
	 * @param string $key     Idempotency key.
	 * @return array|null
	 */
	public static function get_idempotent_response( $post_id, $key ) {
		$key = self::normalize_idempotency_key( $key );
		if ( '' === $key || $post_id <= 0 ) {
			return null;
		}
		$cached = get_transient( self::idempotency_transient_key( $post_id, $key ) );
		return is_array( $cached ) ? $cached : null;
	}

	/**
	 * Store idempotent update response.
	 *
	 * @param int    $post_id  Post ID.
	 * @param string $key      Idempotency key.
	 * @param array  $response Response body.
	 * @return void
	 */
	public static function store_idempotent_response( $post_id, $key, array $response ) {
		$key = self::normalize_idempotency_key( $key );
		if ( '' === $key || $post_id <= 0 ) {
			return;
		}
		set_transient( self::idempotency_transient_key( $post_id, $key ), $response, self::IDEMPOTENCY_TTL );
	}

	/**
	 * @param int    $post_id Post ID.
	 * @param string $key     Key.
	 * @return string
	 */
	private static function idempotency_transient_key( $post_id, $key ) {
		return 'seovault_faq_conn_idemp_' . (int) $post_id . '_' . md5( $key );
	}

	/**
	 * @param array  $graph Graph.
	 * @param string $type  Type.
	 * @return array<int,array>
	 */
	private static function find_nodes_by_type( array $graph, $type ) {
		$out = array();
		if ( empty( $graph['@graph'] ) || ! is_array( $graph['@graph'] ) ) {
			return $out;
		}
		foreach ( $graph['@graph'] as $node ) {
			if ( ! is_array( $node ) ) {
				continue;
			}
			$node_type = $node['@type'] ?? '';
			if ( $node_type === $type || ( is_array( $node_type ) && in_array( $type, $node_type, true ) ) ) {
				$out[] = $node;
			}
		}
		return $out;
	}

	/**
	 * @param array $emitted    Emitted descriptors.
	 * @param array $source_map Source map.
	 * @return string
	 */
	private static function resolve_faq_source( array $emitted, array $source_map ) {
		foreach ( $emitted as $descriptor ) {
			if ( ! is_array( $descriptor ) ) {
				continue;
			}
			$type = isset( $descriptor['type'] ) ? (string) $descriptor['type'] : '';
			if ( 'FAQPage' !== $type ) {
				continue;
			}
			if ( ! empty( $descriptor['source'] ) ) {
				return sanitize_key( (string) $descriptor['source'] );
			}
			if ( ! empty( $descriptor['kind'] ) ) {
				return sanitize_key( (string) $descriptor['kind'] );
			}
		}

		foreach ( $source_map as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$type = isset( $entry['type'] ) ? (string) $entry['type'] : '';
			if ( 'FAQPage' === $type && ! empty( $entry['source'] ) ) {
				return sanitize_key( (string) $entry['source'] );
			}
			if ( 'FAQPage' === $type && ! empty( $entry['kind'] ) ) {
				return sanitize_key( (string) $entry['kind'] );
			}
		}

		return 'block';
	}

	/**
	 * @param array $emitted    Emitted descriptors.
	 * @param array $source_map Source map.
	 * @return string
	 */
	private static function resolve_howto_source( array $emitted, array $source_map ) {
		foreach ( $emitted as $descriptor ) {
			if ( ! is_array( $descriptor ) ) {
				continue;
			}
			$type = isset( $descriptor['type'] ) ? (string) $descriptor['type'] : '';
			if ( 'HowTo' !== $type ) {
				continue;
			}
			if ( ! empty( $descriptor['source'] ) ) {
				return sanitize_key( (string) $descriptor['source'] );
			}
			if ( ! empty( $descriptor['kind'] ) ) {
				return sanitize_key( (string) $descriptor['kind'] );
			}
		}

		foreach ( $source_map as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$type = isset( $entry['type'] ) ? (string) $entry['type'] : '';
			if ( 'HowTo' === $type && ! empty( $entry['source'] ) ) {
				return sanitize_key( (string) $entry['source'] );
			}
			if ( 'HowTo' === $type && ! empty( $entry['kind'] ) ) {
				return sanitize_key( (string) $entry['kind'] );
			}
		}

		return 'block';
	}
}
