<?php
/**
 * ContentBriefService
 *
 * Shared service that produces an AI-powered content brief for a topic.
 * Used by both the SEO Agent "Brief" tab (full mode, Plus-gated) and the
 * Generate Post wizard (lite mode for free/Pro, full mode for Plus).
 *
 * @package Antimanual
 * @since   3.6.0
 */

namespace Antimanual;

use Antimanual\AIProvider;

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

/**
 * Class ContentBriefService
 *
 * Pure-static service. No request handling — controllers call it.
 */
class ContentBriefService {

	/** Transient prefix for cached competitor snippets. */
	const COMP_CACHE_PREFIX = 'atml_brief_comp_';

	/** Transient prefix for cached full briefs (lighter — keyed by inputs). */
	const BRIEF_CACHE_PREFIX = 'atml_brief_full_';

	/** Cache TTL for competitor snippets (7 days). */
	const COMP_CACHE_TTL = 7 * DAY_IN_SECONDS;

	/** Cache TTL for full briefs (1 day). */
	const BRIEF_CACHE_TTL = DAY_IN_SECONDS;

	/** Bump to invalidate all cached snippets after schema changes. */
	const CACHE_VERSION = 'v1';

	/** Allowed search intents. */
	const INTENTS = [ 'informational', 'commercial', 'transactional', 'navigational' ];

	/**
	 * Generate a content brief.
	 *
	 * @param array $args {
	 *     @type string $topic           Required.
	 *     @type string $focus_keyword   Optional.
	 *     @type string $target_audience Optional.
	 *     @type string $intent          Optional. One of INTENTS. Default 'informational'.
	 *     @type array  $competitor_urls Optional. Up to 5 URLs (full mode only).
	 *     @type string $mode            Optional. 'full' (with competitors) | 'lite' (LLM-only). Default 'full'.
	 *     @type bool   $bypass_cache    Optional. If true, skip the full-brief cache lookup. Default false.
	 * }
	 * @return array|\WP_Error Brief array on success, WP_Error on failure.
	 */
	public static function generate( array $args ) {
		$topic           = sanitize_text_field( (string) ( $args['topic'] ?? '' ) );
		$focus_keyword   = sanitize_text_field( (string) ( $args['focus_keyword'] ?? '' ) );
		$target_audience = sanitize_text_field( (string) ( $args['target_audience'] ?? '' ) );
		$intent          = sanitize_key( (string) ( $args['intent'] ?? 'informational' ) );
		$mode            = ( ( $args['mode'] ?? 'full' ) === 'lite' ) ? 'lite' : 'full';
		$bypass_cache    = ! empty( $args['bypass_cache'] );

		$competitor_urls = [];
		if ( 'full' === $mode ) {
			$competitor_urls = (array) ( $args['competitor_urls'] ?? [] );
			$competitor_urls = array_values( array_filter( array_map( 'esc_url_raw', $competitor_urls ) ) );
			$competitor_urls = array_slice( $competitor_urls, 0, 5 );
		}

		if ( '' === $topic ) {
			return new \WP_Error( 'missing_topic', __( 'Topic is required.', 'antimanual' ) );
		}

		if ( ! in_array( $intent, self::INTENTS, true ) ) {
			$intent = 'informational';
		}

		// Full-brief cache lookup (cheap regen).
		$brief_cache_key = self::BRIEF_CACHE_PREFIX . md5(
			self::CACHE_VERSION . '|' . $mode . '|' . strtolower( $topic ) . '|' . strtolower( $focus_keyword ) . '|' . $intent . '|' . implode( ',', $competitor_urls )
		);
		if ( ! $bypass_cache ) {
			$cached = get_transient( $brief_cache_key );
			if ( is_array( $cached ) ) {
				$cached['from_cache'] = true;
				return $cached;
			}
		}

		// Internal coverage check (always runs — cheap, useful for cannibalization warnings).
		$internal_coverage = self::find_internal_coverage( trim( $topic . ' ' . $focus_keyword ), 6 );

		// Competitor analysis (full mode only).
		$competitor_snippets    = [];
		$failed_urls            = [];
		$wc_stats               = [ 'min_range' => 0, 'max_range' => 0, 'avg' => 0, 'median' => 0 ];
		$must_include           = [];
		$extracted_questions    = [];
		if ( 'full' === $mode && ! empty( $competitor_urls ) ) {
			foreach ( $competitor_urls as $url ) {
				$snippet = self::fetch_competitor_snippet_cached( $url );
				if ( ! empty( $snippet ) ) {
					$competitor_snippets[] = $snippet;
				} else {
					$failed_urls[] = $url;
				}
			}
			$wc_stats            = self::competitor_word_count_stats( $competitor_snippets );
			$must_include        = self::extract_must_include_terms( $competitor_snippets );
			$extracted_questions = self::extract_competitor_questions( $competitor_snippets );
		}

		// Build the LLM prompt.
		$messages = self::build_brief_messages( $topic, $focus_keyword, $target_audience, $intent, $competitor_snippets, $internal_coverage, $wc_stats, $must_include );

		$reply_data = AIProvider::get_reply( $messages );
		if ( isset( $reply_data['error'] ) ) {
			return new \WP_Error( 'ai_failed', (string) $reply_data['error'] );
		}

		$reply = is_array( $reply_data ) && isset( $reply_data['reply'] ) ? (string) $reply_data['reply'] : (string) $reply_data;
		$reply = preg_replace( '/^```(?:json)?\s*/i', '', trim( $reply ) );
		$reply = preg_replace( '/\s*```$/', '', (string) $reply );

		$parsed = json_decode( (string) $reply, true );
		if ( ! is_array( $parsed ) ) {
			return new \WP_Error( 'parse_failed', __( 'Failed to parse AI response.', 'antimanual' ) );
		}

		$brief = self::sanitize( $parsed );

		// Merge LLM questions with extracted questions (extracted are deduped against LLM).
		$existing_q_lc = array_map( 'strtolower', $brief['questions'] );
		$dedup_extracted = [];
		foreach ( $extracted_questions as $q ) {
			if ( ! in_array( strtolower( $q ), $existing_q_lc, true ) ) {
				$dedup_extracted[] = $q;
			}
		}

		// Final response shape.
		$brief['topic']               = $topic;
		$brief['focus_keyword']       = $focus_keyword;
		$brief['target_audience']     = $target_audience;
		$brief['mode']                = $mode;
		$brief['competitor_count']    = count( $competitor_snippets );
		$brief['internal_coverage']   = $internal_coverage;
		$brief['questions_extracted'] = array_slice( $dedup_extracted, 0, 20 );
		$brief['intent_heuristic']    = self::classify_intent_heuristic( $topic, $focus_keyword );
		$brief['failed_urls']         = $failed_urls;
		$brief['from_cache']          = false;
		$brief['generated_at']        = current_time( 'mysql' );

		// Persist for cheap regeneration. Cache only the success payload.
		set_transient( $brief_cache_key, $brief, self::BRIEF_CACHE_TTL );

		return $brief;
	}

	/**
	 * Sanitize a raw brief payload (decoded LLM JSON) into a normalized shape.
	 *
	 * @param array $raw Raw decoded JSON from the AI.
	 * @return array
	 */
	public static function sanitize( array $raw ): array {
		$word_count = absint( $raw['recommended_word_count'] ?? 0 );
		if ( $word_count < 300 ) {
			$word_count = 1500;
		}
		if ( $word_count > 10000 ) {
			$word_count = 10000;
		}

		$headings = [];
		foreach ( (array) ( $raw['headings'] ?? [] ) as $h ) {
			$text = sanitize_text_field( (string) ( is_array( $h ) ? ( $h['text'] ?? '' ) : $h ) );
			$level = (int) ( is_array( $h ) ? ( $h['level'] ?? 2 ) : 2 );
			$level = max( 2, min( 4, $level ) );
			if ( '' === $text ) {
				continue;
			}
			$headings[] = [ 'level' => $level, 'text' => $text ];
		}

		$questions = array_values( array_filter( array_map(
			fn( $q ) => sanitize_text_field( (string) $q ),
			(array) ( $raw['questions'] ?? [] )
		) ) );

		$keywords = array_values( array_filter( array_map(
			fn( $k ) => sanitize_text_field( (string) $k ),
			(array) ( $raw['keywords'] ?? [] )
		) ) );

		$faqs = [];
		foreach ( (array) ( $raw['faqs'] ?? [] ) as $f ) {
			if ( ! is_array( $f ) ) {
				continue;
			}
			$q = sanitize_text_field( (string) ( $f['question'] ?? '' ) );
			$a = sanitize_textarea_field( (string) ( $f['answer'] ?? '' ) );
			if ( '' === $q || '' === $a ) {
				continue;
			}
			$faqs[] = [ 'question' => $q, 'answer' => $a ];
		}

		$angles = array_values( array_filter( array_map(
			fn( $a ) => sanitize_text_field( (string) $a ),
			(array) ( $raw['content_angles'] ?? [] )
		) ) );

		$entities = array_values( array_filter( array_map(
			fn( $e ) => sanitize_text_field( (string) $e ),
			(array) ( $raw['entities'] ?? [] )
		) ) );

		$intent = sanitize_key( (string) ( $raw['search_intent'] ?? 'informational' ) );
		if ( ! in_array( $intent, self::INTENTS, true ) ) {
			$intent = 'informational';
		}

		return [
			'recommended_word_count' => $word_count,
			'search_intent'          => $intent,
			'headings'               => array_slice( $headings,  0, 20 ),
			'questions'              => array_slice( $questions, 0, 15 ),
			'keywords'               => array_slice( $keywords,  0, 30 ),
			'faqs'                   => array_slice( $faqs,      0, 8 ),
			'content_angles'         => array_slice( $angles,    0, 8 ),
			'entities'               => array_slice( $entities,  0, 20 ),
		];
	}

	/**
	 * Heuristically classify search intent from the topic + focus keyword.
	 *
	 * Rule-based pattern matching — runs locally without an LLM call.
	 * Inspired by 60 SEO Tools/keyword_intent_classifier.py.
	 *
	 * @param string $topic         Topic text.
	 * @param string $focus_keyword Optional focus keyword.
	 * @return array { intent: string, confidence: float } 0..1 confidence.
	 */
	public static function classify_intent_heuristic( string $topic, string $focus_keyword = '' ): array {
		$haystack = strtolower( trim( $topic . ' ' . $focus_keyword ) );

		if ( '' === $haystack ) {
			return [ 'intent' => 'informational', 'confidence' => 0.0 ];
		}

		$patterns = [
			'transactional' => [
				'/\b(buy|purchase|order|shop|cheap|deal|coupon|discount|price|pricing|cost|free shipping|sale)\b/',
			],
			'commercial'    => [
				'/\b(best|top|review|reviews|compare|comparison|vs|alternative|alternatives|pros and cons|worth it|recommended|ranked)\b/',
			],
			'navigational'  => [
				'/\b(login|log in|sign in|sign up|dashboard|account|app|download|official|homepage|website)\b/',
			],
		];

		$scores = [ 'informational' => 0, 'commercial' => 0, 'transactional' => 0, 'navigational' => 0 ];
		foreach ( $patterns as $intent => $regexes ) {
			foreach ( $regexes as $rx ) {
				if ( preg_match_all( $rx, $haystack, $m ) ) {
					$scores[ $intent ] += count( $m[0] );
				}
			}
		}

		// Informational signals (question words / instructional verbs).
		if ( preg_match_all( '/\b(how|what|why|when|where|who|guide|tutorial|learn|explain|tips|examples)\b/', $haystack, $m ) ) {
			$scores['informational'] += count( $m[0] );
		}

		arsort( $scores );
		$top_intent = (string) array_key_first( $scores );
		$top_score  = $scores[ $top_intent ];

		if ( 0 === $top_score ) {
			return [ 'intent' => 'informational', 'confidence' => 0.3 ];
		}

		$total = max( 1, array_sum( $scores ) );
		$confidence = min( 1.0, round( $top_score / $total, 2 ) );

		return [ 'intent' => $top_intent, 'confidence' => $confidence ];
	}

	/**
	 * Extract questions from competitor pages (sentences ending in ? + question-headings).
	 *
	 * Inspired by 60 SEO Tools/question_extractor.py.
	 *
	 * @param array<int, array> $snippets Competitor snippets.
	 * @return array<int, string> Up to 30 unique questions (caller may cap further).
	 */
	public static function extract_competitor_questions( array $snippets ): array {
		$question_starters = [
			'how','what','why','when','where','who','which','can','does','is','are','do','will','should','could','would',
		];

		$out   = [];
		$seen  = [];

		foreach ( $snippets as $s ) {
			// Headings starting with question words.
			$all_headings = array_merge(
				$s['headings_by_level']['h2'] ?? [],
				$s['headings_by_level']['h3'] ?? []
			);
			foreach ( $all_headings as $h ) {
				$h_trimmed = trim( (string) $h );
				if ( '' === $h_trimmed ) {
					continue;
				}
				$first = strtolower( strtok( $h_trimmed, " \t\n" ) );
				if ( ! in_array( $first, $question_starters, true ) ) {
					continue;
				}
				$normalized = rtrim( $h_trimmed, ' ?.!' ) . '?';
				$key = strtolower( $normalized );
				if ( isset( $seen[ $key ] ) ) {
					continue;
				}
				$seen[ $key ] = true;
				$out[]        = $normalized;
				if ( count( $out ) >= 30 ) break 2;
			}
		}

		return $out;
	}

	/**
	 * Cached wrapper around fetch_competitor_snippet.
	 *
	 * @param string $url Competitor URL.
	 * @return array|null Structured snippet or null on fetch failure.
	 */
	public static function fetch_competitor_snippet_cached( string $url ): ?array {
		$key    = self::COMP_CACHE_PREFIX . md5( self::CACHE_VERSION . '|' . $url );
		$cached = get_transient( $key );
		if ( is_array( $cached ) ) {
			return $cached;
		}
		$snippet = self::fetch_competitor_snippet( $url );
		if ( is_array( $snippet ) ) {
			set_transient( $key, $snippet, self::COMP_CACHE_TTL );
		}
		return $snippet;
	}

	/**
	 * Fetch and structurally extract a competitor page.
	 *
	 * @param string $url External URL.
	 * @return array|null
	 */
	public static function fetch_competitor_snippet( string $url ): ?array {
		$response = wp_remote_get( $url, [
			'timeout'     => 20,
			'redirection' => 3,
			'user-agent'  => 'Mozilla/5.0 (compatible; AntimanualSEO/1.0)',
		] );

		if ( is_wp_error( $response ) ) {
			return null;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( $code < 200 || $code >= 400 ) {
			return null;
		}

		$body = (string) wp_remote_retrieve_body( $response );
		if ( '' === $body ) {
			return null;
		}

		$title = '';
		if ( preg_match( '/<title[^>]*>(.*?)<\/title>/is', $body, $m ) ) {
			$title = trim( wp_strip_all_tags( $m[1] ) );
		}

		$headings_by_level = [ 'h1' => [], 'h2' => [], 'h3' => [], 'h4' => [] ];
		if ( preg_match_all( '/<h([1-4])[^>]*>(.*?)<\/h\1>/is', $body, $matches, PREG_SET_ORDER ) ) {
			foreach ( $matches as $m ) {
				$level = 'h' . $m[1];
				$text  = trim( wp_strip_all_tags( $m[2] ) );
				if ( '' !== $text && strlen( $text ) < 200 ) {
					$headings_by_level[ $level ][] = $text;
				}
			}
		}

		$headings_flat = array_merge(
			$headings_by_level['h1'],
			$headings_by_level['h2'],
			$headings_by_level['h3']
		);

		$plain_text = wp_strip_all_tags( $body );
		$word_count = str_word_count( $plain_text );
		$freq       = self::word_frequencies( $plain_text );

		return [
			'url'               => $url,
			'title'             => $title,
			'headings'          => array_slice( $headings_flat, 0, 30 ),
			'headings_by_level' => $headings_by_level,
			'word_count'        => $word_count,
			'freq'              => $freq,
		];
	}

	/**
	 * Derive must-include terms from competitor pages.
	 *
	 * Terms appearing in ≥40% of pages, ranked by avg frequency. Top 20.
	 *
	 * @param array<int, array> $snippets Competitor snippets.
	 * @return array<string>
	 */
	public static function extract_must_include_terms( array $snippets ): array {
		$n = count( $snippets );
		if ( 0 === $n ) {
			return [];
		}
		$threshold = max( 1, (int) ceil( $n * 0.4 ) );

		$term_data = [];
		foreach ( $snippets as $snippet ) {
			foreach ( (array) ( $snippet['freq'] ?? [] ) as $word => $cnt ) {
				if ( ! isset( $term_data[ $word ] ) ) {
					$term_data[ $word ] = [ 'sum' => 0, 'docs' => 0 ];
				}
				$term_data[ $word ]['sum']  += $cnt;
				$term_data[ $word ]['docs'] += 1;
			}
		}

		$must = [];
		foreach ( $term_data as $word => $data ) {
			if ( $data['docs'] >= $threshold ) {
				$must[ $word ] = $data['sum'] / $n;
			}
		}
		arsort( $must );
		return array_slice( array_keys( $must ), 0, 20 );
	}

	/**
	 * P25/P75 word-count stats from competitor snippets.
	 *
	 * @param array<int, array> $snippets Competitor snippets.
	 * @return array{min_range:int, max_range:int, avg:int, median:int}
	 */
	public static function competitor_word_count_stats( array $snippets ): array {
		$counts = array_filter( array_column( $snippets, 'word_count' ) );
		if ( empty( $counts ) ) {
			return [ 'min_range' => 0, 'max_range' => 0, 'avg' => 0, 'median' => 0 ];
		}
		sort( $counts );
		$n      = count( $counts );
		$avg    = (int) round( array_sum( $counts ) / $n );
		$median = $n % 2 === 0
			? (int) round( ( $counts[ $n / 2 - 1 ] + $counts[ $n / 2 ] ) / 2 )
			: $counts[ (int) floor( $n / 2 ) ];
		$p25 = $counts[ (int) floor( ( $n - 1 ) * 0.25 ) ];
		$p75 = $counts[ (int) floor( ( $n - 1 ) * 0.75 ) ];
		return [
			'min_range' => (int) $p25,
			'max_range' => (int) $p75,
			'avg'       => $avg,
			'median'    => $median,
		];
	}

	/**
	 * Word-frequency map (stop-word filtered, words ≥ 4 chars).
	 *
	 * @param string $text Plain text.
	 * @return array<string,int>
	 */
	public static function word_frequencies( string $text ): array {
		static $stopwords = null;
		if ( null === $stopwords ) {
			$stopwords = array_flip( [
				'about','above','after','again','against','also','and','any','are','because','been','before',
				'being','below','between','both','but','could','does','doing','down','during','each','few',
				'for','from','further','had','has','have','having','her','here','hers','herself','him','himself',
				'his','how','into','itself','just','more','most','myself','nor','not','now','off','once','only',
				'other','our','ours','ourselves','out','over','own','same','should','some','such','than','that',
				'the','their','theirs','them','themselves','then','there','these','they','this','those','through',
				'too','under','until','very','was','were','what','when','where','which','while','who','whom',
				'why','will','with','you','your','yours','yourself','yourselves',
			] );
		}
		$text  = strtolower( $text );
		$text  = preg_replace( '/[^a-z0-9\s]/', ' ', $text );
		$parts = preg_split( '/\s+/', (string) $text, -1, PREG_SPLIT_NO_EMPTY );
		$freq  = [];
		foreach ( $parts as $tok ) {
			if ( strlen( $tok ) < 4 || isset( $stopwords[ $tok ] ) ) {
				continue;
			}
			$freq[ $tok ] = ( $freq[ $tok ] ?? 0 ) + 1;
		}
		return $freq;
	}

	/**
	 * Find existing internal posts on a topic (for cannibalization warnings).
	 *
	 * @param string $topic Topic text.
	 * @param int    $limit Max posts to return.
	 * @return array<int, array{post_id:int,title:string,url:string}>
	 */
	public static function find_internal_coverage( string $topic, int $limit = 6 ): array {
		$topic = trim( $topic );
		if ( '' === $topic ) {
			return [];
		}

		$query = new \WP_Query( [
			'post_type'      => [ 'post', 'page' ],
			'post_status'    => 'publish',
			'posts_per_page' => $limit,
			's'              => $topic,
			'fields'         => 'ids',
		] );

		$out = [];
		foreach ( $query->posts as $id ) {
			$id    = (int) $id;
			$out[] = [
				'post_id' => $id,
				'title'   => get_the_title( $id ),
				'url'     => get_permalink( $id ),
			];
		}
		return $out;
	}

	/**
	 * Build the system + user message pair the LLM uses to produce the brief.
	 *
	 * @param string $topic            Topic.
	 * @param string $focus_keyword    Focus keyword (may be empty).
	 * @param string $target_audience  Audience (may be empty).
	 * @param string $intent           Search intent.
	 * @param array  $competitor_snippets Competitor snippets (may be empty for lite mode).
	 * @param array  $internal_coverage Existing posts on the topic.
	 * @param array  $wc_stats         Word-count stats.
	 * @param array  $must_include     Must-include terms.
	 * @return array<int, array{role:string, content:string}>
	 */
	private static function build_brief_messages( string $topic, string $focus_keyword, string $target_audience, string $intent, array $competitor_snippets, array $internal_coverage, array $wc_stats, array $must_include ): array {
		$lines   = [];
		$lines[] = 'You are an expert SEO content strategist. Produce a research brief for a new article.';
		$lines[] = 'Topic: ' . $topic;

		if ( '' !== $focus_keyword ) {
			$lines[] = 'Focus keyword: ' . $focus_keyword;
		}
		if ( '' !== $target_audience ) {
			$lines[] = 'Target audience: ' . $target_audience;
		}
		$lines[] = 'Search intent: ' . $intent;

		if ( $wc_stats['min_range'] > 0 ) {
			$lines[] = sprintf(
				'Competitor word-count range: %d–%d words (P25–P75). Avg: %d. Median: %d. Set recommended_word_count within this range.',
				$wc_stats['min_range'],
				$wc_stats['max_range'],
				$wc_stats['avg'],
				$wc_stats['median']
			);
		}

		if ( ! empty( $must_include ) ) {
			$lines[] = 'Must-include terms (found across ≥40% of competitor pages — use these throughout the article):';
			$lines[] = implode( ', ', $must_include );
		}

		if ( ! empty( $internal_coverage ) ) {
			$lines[] = 'Existing internal pages on the same topic (avoid duplicating these angles):';
			foreach ( $internal_coverage as $p ) {
				$lines[] = '- ' . $p['title'];
			}
		}

		if ( ! empty( $competitor_snippets ) ) {
			$lines[] = 'Competitor pages (analyze heading structure for gaps and common sections):';
			foreach ( $competitor_snippets as $s ) {
				$lines[] = '--- ' . $s['url'];
				if ( ! empty( $s['title'] ) ) {
					$lines[] = 'Title: ' . $s['title'];
				}
				$h2s = $s['headings_by_level']['h2'] ?? [];
				if ( ! empty( $h2s ) ) {
					$lines[] = 'H2s: ' . implode( ' | ', array_slice( $h2s, 0, 12 ) );
				}
				$h3s = $s['headings_by_level']['h3'] ?? [];
				if ( ! empty( $h3s ) ) {
					$lines[] = 'H3s: ' . implode( ' | ', array_slice( $h3s, 0, 8 ) );
				}
				if ( ! empty( $s['word_count'] ) ) {
					$lines[] = 'Word count: ' . $s['word_count'];
				}
			}
		}

		$lines[] = 'Respond with ONLY valid JSON, no markdown, matching exactly this shape:';
		$lines[] = '{'
			. '"recommended_word_count":int,'
			. '"search_intent":"informational|commercial|transactional|navigational",'
			. '"headings":[{"level":2,"text":"..."}],'
			. '"questions":["..."],'
			. '"keywords":["..."],'
			. '"faqs":[{"question":"...","answer":"..."}],'
			. '"content_angles":["..."],'
			. '"entities":["..."]'
			. '}';
		$lines[] = 'Rules: 6–12 headings, 5–10 questions, 8–20 supporting keywords, 3–6 FAQs, 2–4 content angles.';

		return [
			[ 'role' => 'system', 'content' => 'You write concise, structured SEO briefs. Reply with valid JSON only.' ],
			[ 'role' => 'user',   'content' => implode( "\n", $lines ) ],
		];
	}
}
