<?php
/**
 * Canonical normalization and quality contract for Niwa-managed blog posts.
 *
 * @package Niwa_AI
 */

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

class Niwa_AI_Blog_Quality_Gate {

	const POLICY_VERSION         = '1.0.0';
	const META_MANAGED           = '_niwa_blog_managed';
	const META_POLICY_VERSION    = '_niwa_blog_policy_version';
	const META_SOURCE_HASH       = '_niwa_blog_source_hash';
	const META_SOURCE_CONTENT    = '_niwa_blog_source_content';
	const META_SOURCE_FORMAT     = '_niwa_blog_source_format';
	const META_QUALITY_REPORT    = '_niwa_blog_quality_report';
	const META_SELECTED_LINKS    = '_niwa_blog_selected_links';
	const META_FOCUS_KEYWORD     = '_niwa_blog_focus_keyword';
	const META_SEARCH_INTENT     = '_niwa_blog_search_intent';
	const META_AUDIENCE          = '_niwa_blog_audience';
	const META_MONEY_PAGE        = '_niwa_blog_money_page';
	const META_LANGUAGE          = '_niwa_blog_language';
	const META_EVIDENCE_URLS     = '_niwa_blog_evidence_urls';
	const CONTENT_MAP_TRANSIENT  = 'niwa_bloging_site_content_map';

	/** @var Niwa_AI_Humanizer|null */
	private $humanizer;

	public function __construct( $humanizer = null ) {
		$this->humanizer = $humanizer instanceof Niwa_AI_Humanizer ? $humanizer : null;
	}

	/**
	 * Normalize a proposal, apply deterministic repairs, then audit it.
	 *
	 * @param array $proposal Proposed post state.
	 * @param array $context  Published content map and execution context.
	 * @return array{proposal:array,report:array}
	 */
	public function prepare( $proposal, $context = array() ) {
		$autofixes = array();
		$proposal  = $this->normalize( is_array( $proposal ) ? $proposal : array(), $autofixes );
		$report    = $this->audit( $proposal, is_array( $context ) ? $context : array(), $autofixes );

		return array(
			'proposal' => $proposal,
			'report'   => $report,
		);
	}

	/**
	 * Stable hash used for managed-state and scheduled approval checks.
	 *
	 * @param array $proposal Current or proposed state.
	 * @return string
	 */
	public static function source_hash( $proposal ) {
		$proposal = is_array( $proposal ) ? $proposal : array();
		$state    = array(
			'title'             => isset( $proposal['title'] ) ? (string) $proposal['title'] : '',
			'source_content'    => isset( $proposal['source_content'] ) ? (string) $proposal['source_content'] : ( isset( $proposal['content'] ) ? (string) $proposal['content'] : '' ),
			'rendered_content'  => isset( $proposal['rendered_content'] ) ? (string) $proposal['rendered_content'] : ( isset( $proposal['content'] ) ? (string) $proposal['content'] : '' ),
			'excerpt'           => isset( $proposal['excerpt'] ) ? (string) $proposal['excerpt'] : '',
			'slug'              => isset( $proposal['slug'] ) ? (string) $proposal['slug'] : '',
			'categories'        => array_values( array_map( 'strval', isset( $proposal['categories'] ) ? (array) $proposal['categories'] : array() ) ),
			'tags'              => array_values( array_map( 'strval', isset( $proposal['tags'] ) ? (array) $proposal['tags'] : array() ) ),
			'featured_image_id' => intval( isset( $proposal['featured_image_id'] ) ? $proposal['featured_image_id'] : 0 ),
			'focus_keyword'     => isset( $proposal['focus_keyword'] ) ? (string) $proposal['focus_keyword'] : '',
			'meta_description'  => isset( $proposal['meta_description'] ) ? (string) $proposal['meta_description'] : '',
			'search_intent'     => isset( $proposal['search_intent'] ) ? (string) $proposal['search_intent'] : '',
			'audience'          => isset( $proposal['audience'] ) ? (string) $proposal['audience'] : '',
			'money_page_url'    => isset( $proposal['money_page_url'] ) ? (string) $proposal['money_page_url'] : '',
			'language'          => isset( $proposal['language'] ) ? (string) $proposal['language'] : '',
			'evidence_urls'     => array_values( array_map( 'strval', isset( $proposal['evidence_urls'] ) ? (array) $proposal['evidence_urls'] : array() ) ),
			'author_id'         => intval( isset( $proposal['author_id'] ) ? $proposal['author_id'] : 0 ),
		);

		return hash( 'sha256', (string) wp_json_encode( $state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) );
	}

	/**
	 * Shared internal-link analysis used by the scanner and quality gate.
	 *
	 * @param string $content        Markdown or HTML content.
	 * @param string $title          Proposed title.
	 * @param string $slug           Proposed slug.
	 * @param array  $content_map    Published canonical targets.
	 * @param array  $priority_paths Configured money-page paths.
	 * @param int    $limit          Suggestion limit.
	 * @return array
	 */
	public function analyze_links( $content, $title, $slug, $content_map, $priority_paths = array(), $limit = 6 ) {
		$content_map    = is_array( $content_map ) ? $content_map : array();
		$priority_paths = array_values( array_unique( array_filter( array_map( array( $this, 'normalize_path' ), (array) $priority_paths ) ) ) );
		$known          = array();
		$by_path        = array();

		foreach ( $content_map as $item ) {
			if ( ! is_array( $item ) || empty( $item['path'] ) ) {
				continue;
			}
			$path             = $this->normalize_path( $item['path'] );
			$known[ $path ]   = true;
			$by_path[ $path ] = $item;
		}

		$selected    = array();
		$broken      = array();
		$target_hits = array();
		foreach ( $this->extract_links( $content ) as $link ) {
			$path = $this->internal_path( $link['url'] );
			if ( '' === $path ) {
				continue;
			}
			if ( ! isset( $known[ $path ] ) ) {
				$broken[] = array(
					'url'    => $link['url'],
					'anchor' => $link['anchor'],
					'reason' => 'Internal target is not a current published canonical post, page, or product.',
				);
				continue;
			}

			$item                    = $by_path[ $path ];
			$target_hits[ $path ]     = isset( $target_hits[ $path ] ) ? $target_hits[ $path ] + 1 : 1;
			$selected[]               = array(
				'post_id'        => intval( isset( $item['post_id'] ) ? $item['post_id'] : 0 ),
				'url'            => isset( $item['url'] ) ? (string) $item['url'] : home_url( $path ),
				'path'           => $path,
				'anchor'         => $link['anchor'],
				'source_context' => $this->sentence_context( $content, $link['offset'] ),
				'reason'         => in_array( $path, $priority_paths, true ) ? 'Configured priority money page.' : 'Published internal target used in article context.',
				'type'           => isset( $item['type'] ) ? (string) $item['type'] : '',
			);
		}

		$haystack    = $this->lower( wp_strip_all_tags( $title . ' ' . $content ) );
		$current_slug = sanitize_title( $slug );
		$suggestions = array();
		foreach ( $content_map as $item ) {
			if ( ! is_array( $item ) || empty( $item['path'] ) || ( $current_slug && $current_slug === sanitize_title( isset( $item['slug'] ) ? $item['slug'] : '' ) ) ) {
				continue;
			}
			$path  = $this->normalize_path( $item['path'] );
			$words = $this->significant_words( ( isset( $item['title'] ) ? $item['title'] : '' ) . ' ' . ( isset( $item['slug'] ) ? $item['slug'] : '' ) );
			$hits  = 0;
			foreach ( $words as $word ) {
				if ( false !== strpos( $haystack, $word ) ) {
					$hits++;
				}
			}
			$score = $hits > 0 ? ( $hits / max( 1, count( $words ) ) ) * 10 : 0;
			if ( in_array( $path, $priority_paths, true ) ) {
				$score += 6;
			}
			if ( $score < 2.5 ) {
				continue;
			}
			$suggestions[] = array(
				'post_id' => intval( isset( $item['post_id'] ) ? $item['post_id'] : 0 ),
				'url'     => isset( $item['url'] ) ? (string) $item['url'] : home_url( $path ),
				'path'    => $path,
				'title'   => isset( $item['title'] ) ? (string) $item['title'] : '',
				'type'    => isset( $item['type'] ) ? (string) $item['type'] : '',
				'score'   => round( $score, 2 ),
				'reason'  => in_array( $path, $priority_paths, true ) ? 'Relevant configured priority target.' : 'Title and slug concepts overlap the article.',
			);
		}
		usort( $suggestions, static function ( $a, $b ) { return $b['score'] <=> $a['score']; } );

		return array(
			'selected'          => $selected,
			'broken'            => $broken,
			'suggestions'       => array_slice( $suggestions, 0, max( 1, min( 10, intval( $limit ) ) ) ),
			'duplicate_targets' => array_values( array_keys( array_filter( $target_hits, static function ( $count ) { return $count > 1; } ) ) ),
			'priority_paths'    => $priority_paths,
		);
	}

	/**
	 * Delete the shared map only for content types which can become link targets.
	 *
	 * @param int $post_id Changed post ID.
	 * @return void
	 */
	public static function invalidate_content_map( $post_id = 0 ) {
		$post_id = intval( $post_id );
		$type    = $post_id > 0 && function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : '';
		if ( $post_id <= 0 || in_array( $type, array( 'post', 'page', 'product' ), true ) ) {
			delete_transient( self::CONTENT_MAP_TRANSIENT );
		}
	}

	private function normalize( $proposal, &$autofixes ) {
		$proposal['title']   = trim( wp_strip_all_tags( isset( $proposal['title'] ) ? $proposal['title'] : '' ) );
		$proposal['content'] = trim( isset( $proposal['content'] ) ? (string) $proposal['content'] : '' );
		$proposal['format']  = 'html' === ( isset( $proposal['format'] ) ? sanitize_key( $proposal['format'] ) : '' ) ? 'html' : 'markdown';

		if ( $this->humanizer && '' !== $proposal['content'] ) {
			$before              = $proposal['content'];
			$proposal['content'] = $this->humanizer->apply( $proposal['content'] );
			if ( $before !== $proposal['content'] ) {
				$autofixes[] = array( 'code' => 'humanizer_safe_fixes', 'message' => 'Applied deterministic punctuation, filler, and repetition cleanup.' );
			}
		}

		$proposal['slug'] = sanitize_title( isset( $proposal['slug'] ) && '' !== trim( (string) $proposal['slug'] ) ? $proposal['slug'] : $proposal['title'] );
		$plain            = trim( preg_replace( '/\s+/u', ' ', preg_replace( '/[#*_>`\[\]()!-]+/u', ' ', wp_strip_all_tags( $proposal['content'] ) ) ) );
		$excerpt          = trim( wp_strip_all_tags( isset( $proposal['excerpt'] ) ? $proposal['excerpt'] : '' ) );
		if ( '' === $excerpt && '' !== $plain ) {
			$excerpt              = $this->substring( $plain, 0, 155 );
			$proposal['excerpt'] = rtrim( $excerpt, " \t\n\r\0\x0B,.;:" ) . ( $this->length( $plain ) > 155 ? '...' : '' );
			$autofixes[]         = array( 'code' => 'excerpt_derived', 'message' => 'Derived a concise excerpt from the article because none was supplied.' );
		} else {
			$proposal['excerpt'] = $excerpt;
		}
		$proposal['meta_description'] = trim( wp_strip_all_tags( isset( $proposal['meta_description'] ) && '' !== trim( (string) $proposal['meta_description'] ) ? $proposal['meta_description'] : $proposal['excerpt'] ) );

		$focus = trim( wp_strip_all_tags( isset( $proposal['focus_keyword'] ) ? $proposal['focus_keyword'] : '' ) );
		if ( '' === $focus ) {
			$focus = implode( ' ', array_slice( preg_split( '/\s+/u', $proposal['title'], -1, PREG_SPLIT_NO_EMPTY ), 0, 6 ) );
			if ( '' !== $focus ) {
				$autofixes[] = array( 'code' => 'focus_keyword_derived', 'message' => 'Derived the primary query from the post title.' );
			}
		}
		$proposal['focus_keyword'] = $focus;
		$proposal['search_intent'] = trim( wp_strip_all_tags( isset( $proposal['search_intent'] ) ? $proposal['search_intent'] : '' ) );
		if ( '' === $proposal['search_intent'] ) {
			$proposal['search_intent'] = 'informational support for the primary query';
			$autofixes[] = array( 'code' => 'search_intent_derived', 'message' => 'Recorded a conservative informational-support intent.' );
		}
		$proposal['audience'] = trim( wp_strip_all_tags( isset( $proposal['audience'] ) ? $proposal['audience'] : '' ) );
		if ( '' === $proposal['audience'] ) {
			$proposal['audience'] = 'site visitors researching the primary query';
			$autofixes[] = array( 'code' => 'audience_derived', 'message' => 'Recorded a conservative audience description.' );
		}

		$proposal['money_page_url'] = isset( $proposal['money_page_url'] ) ? esc_url_raw( trim( (string) $proposal['money_page_url'] ) ) : '';
		$language = trim( wp_strip_all_tags( isset( $proposal['language'] ) ? $proposal['language'] : '' ) );
		if ( ! preg_match( '/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/', $language ) ) {
			$language = str_replace( '_', '-', (string) get_bloginfo( 'language' ) );
			if ( ! preg_match( '/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/', $language ) ) {
				$language = 'en-US';
			}
			$autofixes[] = array( 'code' => 'language_repaired', 'message' => 'Replaced an invalid article language with the WordPress site language.' );
		}
		$proposal['language']        = $language;
		$proposal['evidence_urls']   = array_values( array_unique( array_filter( array_map( 'esc_url_raw', isset( $proposal['evidence_urls'] ) ? (array) $proposal['evidence_urls'] : array() ) ) ) );
		$proposal['categories']      = $this->normalize_terms( isset( $proposal['categories'] ) ? $proposal['categories'] : array(), 2, 'category_limit', $autofixes );
		$proposal['tags']            = $this->normalize_terms( isset( $proposal['tags'] ) ? $proposal['tags'] : array(), 3, 'tag_limit', $autofixes );
		$proposal['featured_image_id'] = max( 0, intval( isset( $proposal['featured_image_id'] ) ? $proposal['featured_image_id'] : 0 ) );
		$proposal['author_id']         = max( 0, intval( isset( $proposal['author_id'] ) ? $proposal['author_id'] : 0 ) );

		$accent = isset( $proposal['accent_color'] ) ? trim( (string) $proposal['accent_color'] ) : '';
		$valid  = function_exists( 'sanitize_hex_color' ) ? sanitize_hex_color( $accent ) : ( preg_match( '/^#[0-9a-f]{6}$/i', $accent ) ? strtolower( $accent ) : '' );
		if ( ! $valid ) {
			$valid = '#3b82f6';
			if ( '' !== $accent ) {
				$autofixes[] = array( 'code' => 'accent_color_repaired', 'message' => 'Replaced an invalid accent color with #3b82f6.' );
			}
		}
		$proposal['accent_color'] = $valid;

		return $proposal;
	}

	private function audit( $proposal, $context, $autofixes ) {
		$checks   = array();
		$critical = array();
		$warnings = array();
		$facts    = array();
		$content  = (string) $proposal['content'];
		$plain    = trim( preg_replace( '/\s+/u', ' ', preg_replace( '/[#*_>`\[\]()!-]+/u', ' ', wp_strip_all_tags( $content ) ) ) );
		$words    = preg_split( '/[^\p{L}\p{N}]+/u', $plain, -1, PREG_SPLIT_NO_EMPTY );
		$word_count = is_array( $words ) ? count( $words ) : 0;

		$this->check( $checks, $critical, $warnings, 'title_present', '' !== $proposal['title'], true, 'A useful post title is required.', 'Supply a specific title that states the article subject or outcome.' );
		$this->check( $checks, $critical, $warnings, 'content_present', $word_count >= 1, true, 'Article content is empty.', 'Supply the complete article body.' );
		$this->check( $checks, $critical, $warnings, 'useful_depth', $word_count >= 300, true, 'The article is too thin to publish.', 'Add concrete, non-repetitive guidance; full articles normally target at least 800 useful words.' );
		if ( $word_count < 800 && $word_count >= 300 ) {
			$warnings[] = $this->issue( 'below_default_depth', 'The article is below the default 800-word full-blog target.', 'Keep it short only when the search intent is fully answered; otherwise add useful evidence, examples, or steps.' );
		}

		$headings = $this->extract_headings( $content );
		$opening_parts = preg_split( '/^\s*##\s+|<h2\b/imu', $content, 2 );
		$opening_text  = isset( $opening_parts[0] ) ? preg_replace( '/^\s*#{1,6}\s+.+$/mu', '', $opening_parts[0] ) : '';
		$opening_words = preg_split( '/[^\p{L}\p{N}]+/u', wp_strip_all_tags( $opening_text ), -1, PREG_SPLIT_NO_EMPTY );
		$opening_word_count = is_array( $opening_words ) ? count( $opening_words ) : 0;
		$this->check( $checks, $critical, $warnings, 'useful_opening', $opening_word_count >= 12, true, 'The article does not provide a useful answer or concrete problem before its first H2.', 'Open with at least one concise substantive paragraph before the first body section.' );
		$h1_count = count( array_filter( $headings, static function ( $item ) { return 1 === $item['level']; } ) );
		$this->check( $checks, $critical, $warnings, 'theme_owns_h1', 0 === $h1_count, true, 'Body content contains an H1.', 'Remove body H1 headings; the WordPress theme/post title owns the single rendered H1.' );
		$hierarchy_ok = $this->heading_hierarchy_is_valid( $headings );
		$this->check( $checks, $critical, $warnings, 'heading_hierarchy', $hierarchy_ok, true, 'Body headings do not follow H2 sections and H3 subsections.', 'Start body sections at H2, use H3 only beneath H2, and do not skip heading levels.' );
		$this->check( $checks, $critical, $warnings, 'body_sections', count( array_filter( $headings, static function ( $item ) { return 2 === $item['level']; } ) ) >= 2, true, 'The article has fewer than two H2 body sections.', 'Split the useful answer into at least two clear H2 sections.' );

		$raw_assets = preg_match( '/<(?:style|script)\b|application\/ld\+json|@(?:media|keyframes|import)\b/i', $content );
		$this->check( $checks, $critical, $warnings, 'no_executable_payload', ! $raw_assets, true, 'Executable CSS, JavaScript, or JSON-LD appears in post content.', 'Remove raw assets from the article body; Niwa stores CSS, progress JavaScript, and schema in managed post meta.' );

		$image_report = $this->audit_images( $proposal, $content );
		$this->check( $checks, $critical, $warnings, 'image_alt_text', empty( $image_report['missing_alt'] ), true, 'One or more inline images have empty alt text.', 'Add concise descriptive alt text to every inline image.' );
		$this->check( $checks, $critical, $warnings, 'no_unmanaged_image_hotlinks', empty( $image_report['external_hotlinks'] ), true, 'One or more inline images are externally hotlinked instead of being managed attachments.', 'Import each authorized image into the WordPress Media Library, preserve its source/license data, and use the attachment URL.' );
		$this->check( $checks, $critical, $warnings, 'featured_image_valid', ! empty( $image_report['featured_valid'] ), true, 'The supplied featured image is not an available image attachment.', 'Use a real image attachment from the WordPress Media Library or omit the featured image.' );
		$this->check( $checks, $critical, $warnings, 'featured_image_alt', empty( $image_report['featured_missing_alt'] ), true, 'The supplied featured image has no descriptive alt text.', 'Add descriptive alt text to the selected Media Library attachment.' );
		$author_valid = $proposal['author_id'] > 0 && function_exists( 'get_userdata' ) && (bool) get_userdata( $proposal['author_id'] );
		$this->check( $checks, $critical, $warnings, 'real_wordpress_author', $author_valid, true, 'The article has no valid WordPress author.', 'Select a real WordPress author ID before creating or publishing the post.' );
		if ( ! empty( $image_report['featured_duplicate'] ) ) {
			$critical[] = $this->issue( 'duplicate_featured_image', 'The featured image is also rendered inside the article body.', 'Remove the duplicate inline image or choose another featured image.' );
		}

		$content_map    = isset( $context['content_map'] ) && is_array( $context['content_map'] ) ? $context['content_map'] : array();
		$priority_paths = isset( $context['priority_paths'] ) ? (array) $context['priority_paths'] : array();
		$link_report    = $this->analyze_links( $content, $proposal['title'], $proposal['slug'], $content_map, $priority_paths, 6 );
		$this->check( $checks, $critical, $warnings, 'internal_targets_valid', empty( $link_report['broken'] ), true, 'The article contains broken or non-published internal links.', 'Replace each broken link with a current canonical target listed in link_report.suggestions.' );
		$required_links = $word_count >= 500 && ! empty( $content_map ) ? 3 : 0;
		$this->check( $checks, $critical, $warnings, 'contextual_internal_links', count( $link_report['selected'] ) >= $required_links, $required_links > 0, 'The article does not contain enough verified contextual internal links.', 'Naturally place at least three relevant links from link_report.suggestions inside explanatory sentences.' );
		if ( ! empty( $link_report['duplicate_targets'] ) ) {
			$warnings[] = $this->issue( 'duplicate_internal_targets', 'The same internal target is linked repeatedly.', 'Keep one natural link per target unless repetition genuinely helps the reader.' );
		}
		if ( count( $link_report['selected'] ) > 6 ) {
			$warnings[] = $this->issue( 'excessive_internal_links', 'The article uses more than six internal targets.', 'Keep only the most relevant contextual links unless the article length genuinely needs more.' );
		}
		$money_url  = (string) $proposal['money_page_url'];
		$money_path = $this->internal_path( $money_url );
		if ( '' !== $money_url ) {
			$known_money_paths = array();
			foreach ( $content_map as $target ) {
				if ( is_array( $target ) && ! empty( $target['path'] ) ) {
					$known_money_paths[] = $this->normalize_path( $target['path'] );
				}
			}
			$money_target_valid = '' !== $money_path && in_array( $money_path, $known_money_paths, true );
			$this->check( $checks, $critical, $warnings, 'money_page_target_valid', $money_target_valid, true, 'The selected destination money page is not a current published internal target.', 'Choose a published canonical page or product from link_report.suggestions or the configured priority paths.' );
			$has_money = false;
			foreach ( $link_report['selected'] as $selected ) {
				if ( $money_path === $selected['path'] ) {
					$has_money = true;
					break;
				}
			}
			$this->check( $checks, $critical, $warnings, 'money_page_route', $money_target_valid && $has_money, true, 'The selected destination money page is not linked in the article.', 'Place one natural conversion link to the selected money page inside a relevant sentence.' );
		} else {
			$checks[] = array( 'code' => 'money_page_target_valid', 'pass' => true, 'severity' => 'not_applicable' );
			$checks[] = array( 'code' => 'money_page_route', 'pass' => true, 'severity' => 'not_applicable' );
		}

		$collisions = $this->find_collisions( $proposal, $content_map, intval( isset( $context['current_post_id'] ) ? $context['current_post_id'] : 0 ) );
		$this->check( $checks, $critical, $warnings, 'topic_collision', empty( $collisions['critical'] ), true, 'The proposed topic collides with existing published content.', 'Review the reported posts and update, merge, or choose a distinct supporting angle instead of creating a duplicate.' );
		if ( ! empty( $collisions['similar'] ) ) {
			$warnings[] = $this->issue( 'semantic_overlap', 'The proposed topic strongly overlaps existing published content.', 'Confirm that this is a distinct supporting angle before publishing.', array( 'conflicts' => $collisions['similar'] ) );
		}

		$this->check( $checks, $critical, $warnings, 'focus_query', '' !== $proposal['focus_keyword'], true, 'A primary query/focus keyword could not be derived.', 'Supply a specific focus_keyword that matches the article intent.' );
		$this->check( $checks, $critical, $warnings, 'excerpt_present', '' !== $proposal['excerpt'], true, 'The excerpt/meta description is empty.', 'Supply a unique concise summary of the article.' );
		$excerpt_length = $this->length( $proposal['meta_description'] );
		if ( $excerpt_length < 50 || $excerpt_length > 170 ) {
			$warnings[] = $this->issue( 'meta_description_length', 'The meta description is outside the practical 50-170 character range.', 'Tighten it without truncating the useful promise.' );
		}

		$style = $this->audit_style( $content, $proposal['evidence_urls'] );
		$warnings = array_merge( $warnings, $style['warnings'] );
		$checks[] = array( 'code' => 'human_copy_detector', 'pass' => empty( $style['critical'] ), 'severity' => empty( $style['critical'] ) ? 'warning' : 'critical' );
		$critical = array_merge( $critical, $style['critical'] );

		if ( ! empty( $context['require_managed'] ) ) {
			$managed = ! empty( $context['managed'] ) && isset( $context['policy_version'] ) && self::POLICY_VERSION === (string) $context['policy_version'];
			$this->check( $checks, $critical, $warnings, 'managed_current_policy', $managed, true, 'The post is not managed by the current Niwa blog policy.', 'Run update_blog_post through the canonical pipeline before publishing this legacy or stale-policy post.' );
		}

		$passed = count( array_filter( $checks, static function ( $check ) { return ! empty( $check['pass'] ); } ) );
		$total  = count( $checks );
		$facts  = array(
			'word_count'              => $word_count,
			'opening_word_count'      => $opening_word_count,
			'body_h1_count'           => $h1_count,
			'body_h2_count'           => count( array_filter( $headings, static function ( $item ) { return 2 === $item['level']; } ) ),
			'body_h3_count'           => count( array_filter( $headings, static function ( $item ) { return 3 === $item['level']; } ) ),
			'internal_link_count'     => count( $link_report['selected'] ),
			'focus_keyword'           => $proposal['focus_keyword'],
			'search_intent'           => $proposal['search_intent'],
			'audience'                => $proposal['audience'],
			'quality_policy_version'  => self::POLICY_VERSION,
			'source_hash'             => self::source_hash( $proposal ),
			'collision_report'        => $collisions,
			'style_detector'          => $style['facts'],
			'image_report'            => $image_report,
			'author_id'               => $proposal['author_id'],
		);

		return array(
			'pass'            => empty( $critical ),
			'publishable'     => empty( $critical ),
			'score'           => $total > 0 ? intval( round( ( $passed / $total ) * 100 ) ) : 0,
			'checks_total'    => $total,
			'checks'          => $checks,
			'critical'        => array_values( $critical ),
			'warnings'        => array_values( $warnings ),
			'autofixes'       => array_values( $autofixes ),
			'facts'           => $facts,
			'link_report'     => $link_report,
			'taxonomy_report' => isset( $context['taxonomy_report'] ) && is_array( $context['taxonomy_report'] ) ? $context['taxonomy_report'] : array( 'reused' => array(), 'created' => array(), 'skipped' => array() ),
			'render_contract' => array(
				'theme_owns_h1'        => 0 === $h1_count,
				'body_hierarchy'       => 'H2 sections, H3 subsections',
				'no_executable_payload' => ! $raw_assets,
				'scoped_wrapper'       => '.niwa-blog-article',
				'unique_heading_ids'   => true,
			),
		);
	}

	private function check( &$checks, &$critical, &$warnings, $code, $pass, $is_critical, $message, $repair ) {
		$checks[] = array( 'code' => $code, 'pass' => (bool) $pass, 'severity' => $is_critical ? 'critical' : 'warning' );
		if ( $pass ) {
			return;
		}
		$issue = $this->issue( $code, $message, $repair );
		if ( $is_critical ) {
			$critical[] = $issue;
		} else {
			$warnings[] = $issue;
		}
	}

	private function issue( $code, $message, $repair, $extra = array() ) {
		return array_merge( array( 'code' => (string) $code, 'message' => (string) $message, 'repair' => (string) $repair ), is_array( $extra ) ? $extra : array() );
	}

	private function extract_headings( $content ) {
		$headings = array();
		if ( preg_match_all( '/^\s*(#{1,6})\s+(.+?)\s*#*\s*$/mu', $content, $markdown, PREG_SET_ORDER ) ) {
			foreach ( $markdown as $match ) {
				$headings[] = array( 'level' => strlen( $match[1] ), 'text' => trim( wp_strip_all_tags( $match[2] ) ) );
			}
		}
		if ( preg_match_all( '/<h([1-6])\b[^>]*>(.*?)<\/h\1>/is', $content, $html, PREG_SET_ORDER ) ) {
			foreach ( $html as $match ) {
				$headings[] = array( 'level' => intval( $match[1] ), 'text' => trim( wp_strip_all_tags( $match[2] ) ) );
			}
		}
		return $headings;
	}

	private function heading_hierarchy_is_valid( $headings ) {
		$body = array_values( array_filter( $headings, static function ( $item ) { return $item['level'] >= 2; } ) );
		if ( empty( $body ) || 2 !== $body[0]['level'] ) {
			return false;
		}
		$previous = 2;
		foreach ( $body as $heading ) {
			if ( $heading['level'] > 3 || $heading['level'] > $previous + 1 ) {
				return false;
			}
			$previous = $heading['level'];
		}
		return true;
	}

	private function audit_images( $proposal, $content ) {
		$missing    = array();
		$image_urls = array();
		if ( preg_match_all( '/!\[([^\]]*)\]\(([^)]+)\)/u', $content, $markdown, PREG_SET_ORDER ) ) {
			foreach ( $markdown as $match ) {
				$image_urls[] = trim( preg_replace( '/\s+["\'][^"\']*["\']\s*$/u', '', $match[2] ) );
				if ( '' === trim( $match[1] ) ) {
					$missing[] = $match[2];
				}
			}
		}
		if ( preg_match_all( '/<img\b[^>]*>/is', $content, $images ) ) {
			foreach ( $images[0] as $image ) {
				if ( preg_match( '/\bsrc=["\']([^"\']+)["\']/i', $image, $src ) ) {
					$image_urls[] = html_entity_decode( $src[1], ENT_QUOTES, 'UTF-8' );
				}
				if ( ! preg_match( '/\balt=["\']([^"\']+)["\']/i', $image ) ) {
					$missing[] = $image;
				}
			}
		}

		$featured_id    = intval( $proposal['featured_image_id'] );
		$featured_valid = true;
		$featured_missing_alt = false;
		$duplicate      = false;
		if ( $featured_id > 0 ) {
			if ( function_exists( 'get_post_type' ) ) {
				$mime           = function_exists( 'get_post_mime_type' ) ? (string) get_post_mime_type( $featured_id ) : '';
				$featured_valid = 'attachment' === get_post_type( $featured_id ) && 0 === strpos( $mime, 'image/' );
			}
			if ( $featured_valid && function_exists( 'wp_get_attachment_url' ) ) {
				$url       = (string) wp_get_attachment_url( $featured_id );
				$duplicate = '' !== $url && false !== strpos( $content, $url );
			}
			if ( $featured_valid && function_exists( 'get_post_meta' ) ) {
				$featured_missing_alt = '' === trim( (string) get_post_meta( $featured_id, '_wp_attachment_image_alt', true ) );
			}
		}
		$external_hotlinks = array();
		$site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
		foreach ( array_values( array_unique( array_filter( $image_urls ) ) ) as $url ) {
			$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
			if ( '' === $host || $host === $site_host ) {
				continue;
			}
			$attachment_id = function_exists( 'attachment_url_to_postid' ) ? intval( attachment_url_to_postid( $url ) ) : 0;
			if ( $attachment_id > 0 && 'attachment' === get_post_type( $attachment_id ) ) {
				continue;
			}
			$external_hotlinks[] = $url;
		}

		return array( 'missing_alt' => $missing, 'external_hotlinks' => $external_hotlinks, 'featured_valid' => $featured_valid, 'featured_missing_alt' => $featured_missing_alt, 'featured_duplicate' => $duplicate );
	}

	private function audit_style( $content, $evidence_urls ) {
		$warnings   = array();
		$paragraphs = preg_split( '/(?:\R\s*\R|<\/p>)/iu', preg_replace( '/<p\b[^>]*>/i', '', $content ), -1, PREG_SPLIT_NO_EMPTY );
		$lengths    = array();
		$starts     = array();
		foreach ( is_array( $paragraphs ) ? $paragraphs : array() as $paragraph ) {
			$words = preg_split( '/[^\p{L}\p{N}]+/u', wp_strip_all_tags( $paragraph ), -1, PREG_SPLIT_NO_EMPTY );
			if ( ! is_array( $words ) || count( $words ) < 3 ) {
				continue;
			}
			$lengths[] = count( $words );
			$start     = $this->lower( implode( ' ', array_slice( $words, 0, 3 ) ) );
			$starts[ $start ] = isset( $starts[ $start ] ) ? $starts[ $start ] + 1 : 1;
		}
		if ( count( array_filter( $starts, static function ( $count ) { return $count > 1; } ) ) > 0 ) {
			$warnings[] = $this->issue( 'repeated_paragraph_openings', 'Several paragraphs begin with the same phrase.', 'Vary paragraph openings and make each section advance a different point.' );
		}
		if ( count( $lengths ) >= 5 && max( $lengths ) - min( $lengths ) < 8 ) {
			$warnings[] = $this->issue( 'monotonous_paragraph_rhythm', 'Paragraph lengths are unusually uniform.', 'Mix short emphasis paragraphs with fuller explanations where the subject needs them.' );
		}
		$critical = array();
		if ( empty( $evidence_urls ) && preg_match( '/(?:\d+(?:[.,]\d+)?\s*%|[$€£]\s*\d|\d\s*[$€£])/u', $content ) ) {
			$critical[] = $this->issue( 'unsupported_exact_claim', 'The article contains an exact percentage or money claim without supplied evidence.', 'Add a source URL or soften/remove the exact claim.' );
		}

		$plain      = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $content ) ) );
		$sentences  = preg_split( '/(?<=[.!?…])\s+/u', $plain, -1, PREG_SPLIT_NO_EMPTY );
		$phrases    = array();
		foreach ( is_array( $sentences ) ? $sentences : array() as $sentence ) {
			$tokens = preg_split( '/[^\p{L}\p{N}]+/u', $this->lower( $sentence ), -1, PREG_SPLIT_NO_EMPTY );
			if ( ! is_array( $tokens ) || count( $tokens ) < 5 ) {
				continue;
			}
			for ( $i = 0; $i <= count( $tokens ) - 5; $i++ ) {
				$phrase = implode( ' ', array_slice( $tokens, $i, 5 ) );
				$phrases[ $phrase ] = isset( $phrases[ $phrase ] ) ? $phrases[ $phrase ] + 1 : 1;
			}
		}
		$repeated_phrases = array_keys( array_filter( $phrases, static function ( $count ) { return $count >= 3; } ) );
		if ( ! empty( $repeated_phrases ) ) {
			$warnings[] = $this->issue( 'repeated_generic_phrasing', 'The draft repeats the same five-word sequences several times.', 'Replace repeated boilerplate with section-specific facts, examples, constraints, or decisions.', array( 'examples' => array_slice( $repeated_phrases, 0, 5 ) ) );
		}

		$usable_paragraphs = array_values( array_filter( is_array( $paragraphs ) ? $paragraphs : array(), static function ( $paragraph ) { return '' !== trim( wp_strip_all_tags( $paragraph ) ); } ) );
		$opening_closing_overlap = 0;
		if ( count( $usable_paragraphs ) >= 3 ) {
			$opening = $this->significant_words( wp_strip_all_tags( $usable_paragraphs[0] ) );
			$closing = $this->significant_words( wp_strip_all_tags( $usable_paragraphs[ count( $usable_paragraphs ) - 1 ] ) );
			$opening_closing_overlap = count( array_intersect( $opening, $closing ) ) / max( 1, min( count( $opening ), count( $closing ) ) );
			if ( $opening_closing_overlap >= 0.7 ) {
				$warnings[] = $this->issue( 'canned_opening_closing_echo', 'The closing largely repeats the opening instead of adding a concrete next step.', 'End with a specific action, constraint, decision, or consequence that advances the article.' );
			}
		}

		$section_lengths = array();
		$sections = preg_split( '/(?:^\s*##\s+.+$|<h2\b[^>]*>.*?<\/h2>)/imu', $content, -1, PREG_SPLIT_NO_EMPTY );
		foreach ( is_array( $sections ) ? $sections : array() as $section ) {
			$section_words = preg_split( '/[^\p{L}\p{N}]+/u', wp_strip_all_tags( $section ), -1, PREG_SPLIT_NO_EMPTY );
			if ( is_array( $section_words ) && count( $section_words ) >= 20 ) {
				$section_lengths[] = count( $section_words );
			}
		}
		if ( count( $section_lengths ) >= 3 && max( $section_lengths ) - min( $section_lengths ) <= max( 12, intval( array_sum( $section_lengths ) / count( $section_lengths ) * 0.12 ) ) ) {
			$warnings[] = $this->issue( 'excessive_section_symmetry', 'Major sections have unusually uniform length.', 'Let the information determine section depth instead of repeating a template.' );
		}
		$unsourced_quoted_material = empty( $evidence_urls ) && preg_match( '/<blockquote\b|[“"][^”"]{20,}[”"]/u', $content );
		if ( $unsourced_quoted_material ) {
			$warnings[] = $this->issue( 'unsourced_quote_or_anecdote', 'Quoted or anecdotal material appears without supplied source evidence.', 'Supply the source, clearly identify verified first-hand experience, or remove/soften the passage.' );
		}

		return array(
			'warnings' => $warnings,
			'critical' => $critical,
			'facts'    => array(
				'repeated_phrase_count'    => count( $repeated_phrases ),
				'opening_closing_overlap'  => round( $opening_closing_overlap, 2 ),
				'section_lengths'          => $section_lengths,
				'unsourced_quoted_material' => (bool) $unsourced_quoted_material,
				'evidence_url_count'       => count( (array) $evidence_urls ),
			),
		);
	}

	private function find_collisions( $proposal, $content_map, $current_post_id ) {
		$critical = array();
		$similar  = array();
		$title    = $this->lower( $proposal['title'] );
		$slug     = sanitize_title( $proposal['slug'] );
		$focus    = $this->lower( $proposal['focus_keyword'] );
		$tokens   = $this->significant_words( $proposal['title'] . ' ' . $proposal['focus_keyword'] );
		foreach ( $content_map as $item ) {
			if ( ! is_array( $item ) || intval( isset( $item['post_id'] ) ? $item['post_id'] : 0 ) === $current_post_id ) {
				continue;
			}
			$item_title = $this->lower( isset( $item['title'] ) ? $item['title'] : '' );
			$item_slug  = sanitize_title( isset( $item['slug'] ) ? $item['slug'] : '' );
			$item_focus = $this->lower( isset( $item['focus_keyword'] ) ? $item['focus_keyword'] : '' );
			$entry      = array(
				'post_id' => intval( isset( $item['post_id'] ) ? $item['post_id'] : 0 ),
				'url'     => isset( $item['url'] ) ? (string) $item['url'] : home_url( isset( $item['path'] ) ? $item['path'] : '' ),
				'title'   => isset( $item['title'] ) ? (string) $item['title'] : '',
			);
			if ( ( '' !== $title && $title === $item_title ) || ( '' !== $slug && $slug === $item_slug ) || ( '' !== $focus && '' !== $item_focus && $focus === $item_focus ) ) {
				$critical[] = $entry;
				continue;
			}
			$item_tokens = $this->significant_words( $item_title . ' ' . $item_slug . ' ' . $item_focus );
			$overlap     = count( array_intersect( $tokens, $item_tokens ) ) / max( 1, count( array_unique( array_merge( $tokens, $item_tokens ) ) ) );
			if ( $overlap >= 0.6 ) {
				$entry['overlap'] = round( $overlap, 2 );
				$similar[]        = $entry;
			}
		}
		return array( 'critical' => $critical, 'similar' => $similar );
	}

	private function extract_links( $content ) {
		$links = array();
		if ( preg_match_all( '/\[([^\]]+)\]\(([^)\s]+)(?:\s+["\'][^"\']*["\'])?\)/u', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) {
			foreach ( $matches as $match ) {
				$links[] = array( 'anchor' => wp_strip_all_tags( $match[1][0] ), 'url' => html_entity_decode( $match[2][0], ENT_QUOTES, 'UTF-8' ), 'offset' => intval( $match[0][1] ) );
			}
		}
		if ( preg_match_all( '/<a\b[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) {
			foreach ( $matches as $match ) {
				$links[] = array( 'anchor' => wp_strip_all_tags( $match[2][0] ), 'url' => html_entity_decode( $match[1][0], ENT_QUOTES, 'UTF-8' ), 'offset' => intval( $match[0][1] ) );
			}
		}
		return $links;
	}

	private function internal_path( $url ) {
		$url = trim( html_entity_decode( (string) $url, ENT_QUOTES, 'UTF-8' ) );
		if ( '' === $url || 0 === strpos( $url, '#' ) || 0 === strpos( $url, 'mailto:' ) || 0 === strpos( $url, 'tel:' ) ) {
			return '';
		}
		$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
		$host      = wp_parse_url( $url, PHP_URL_HOST );
		if ( $host && $site_host && strtolower( $host ) !== strtolower( $site_host ) ) {
			return '';
		}
		if ( $host || 0 === strpos( $url, '/' ) ) {
			$path = wp_parse_url( $url, PHP_URL_PATH );
			return $this->normalize_path( $path ? $path : '/' );
		}
		return '';
	}

	private function normalize_path( $path ) {
		$path = wp_parse_url( (string) $path, PHP_URL_PATH );
		$path = '/' . ltrim( (string) $path, '/' );
		return '/' === $path ? '/' : rtrim( $path, '/' ) . '/';
	}

	private function sentence_context( $content, $offset ) {
		$plain  = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $content ) ) );
		$length = $this->length( $plain );
		if ( $length <= 220 ) {
			return $plain;
		}
		$ratio = strlen( $content ) > 0 ? max( 0, min( 1, intval( $offset ) / strlen( $content ) ) ) : 0;
		$start = max( 0, intval( ( $length * $ratio ) - 90 ) );
		return trim( $this->substring( $plain, $start, 220 ) );
	}

	private function significant_words( $text ) {
		$tokens = preg_split( '/[^\p{L}\p{N}]+/u', $this->lower( $text ), -1, PREG_SPLIT_NO_EMPTY );
		return array_values( array_unique( array_filter( is_array( $tokens ) ? $tokens : array(), function ( $token ) { return $this->length( $token ) > 3; } ) ) );
	}

	private function normalize_terms( $terms, $limit, $code, &$autofixes ) {
		$normalized = array();
		$seen       = array();
		foreach ( (array) $terms as $term ) {
			$name = trim( wp_strip_all_tags( is_array( $term ) && isset( $term['name'] ) ? $term['name'] : $term ) );
			$key  = $this->lower( $name );
			if ( '' === $name || isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$normalized[] = $name;
		}
		if ( count( $normalized ) > $limit ) {
			$normalized = array_slice( $normalized, 0, $limit );
			$autofixes[] = array( 'code' => $code, 'message' => 'Trimmed taxonomy to the governed maximum of ' . intval( $limit ) . ' terms.' );
		}
		return $normalized;
	}

	private function lower( $value ) {
		return function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) $value, 'UTF-8' ) : strtolower( (string) $value );
	}

	private function length( $value ) {
		return function_exists( 'mb_strlen' ) ? mb_strlen( (string) $value, 'UTF-8' ) : strlen( (string) $value );
	}

	private function substring( $value, $start, $length ) {
		return function_exists( 'mb_substr' ) ? mb_substr( (string) $value, intval( $start ), intval( $length ), 'UTF-8' ) : substr( (string) $value, intval( $start ), intval( $length ) );
	}
}
