<?php

namespace Limb_Chatbot\Includes\Services\Knowledge\Generators;

use Limb_Chatbot\Includes\Data_Objects\Dataset;
use Limb_Chatbot\Includes\Data_Objects\Dataset_Entry;
use Limb_Chatbot\Includes\Exceptions\Error_Codes;
use Limb_Chatbot\Includes\Exceptions\Exception;
use Limb_Chatbot\Includes\Factories\Knowledge_Mapper_Factory;
use Limb_Chatbot\Includes\Integrations\Integrations;
use Limb_Chatbot\Includes\Interfaces\Knowledge_Content_Interface;
use Limb_Chatbot\Includes\Interfaces\Knowledge_Generator_Interface;
use Limb_Chatbot\Includes\Services\Collection;
use Limb_Chatbot\Includes\Services\Helper;
use Limb_Chatbot\Includes\Services\Knowledge\Post_Content_Config_Service;
use Limb_Chatbot\Includes\Services\Knowledge\Builders\Chunk_Entry_Builder;
use Limb_Chatbot\Includes\Services\Knowledge\Chunkers\Heading_Aware_Chunker;
use Limb_Chatbot\Includes\Services\Knowledge\Content_Extractors\Content_Extractor;
use Limb_Chatbot\Includes\Services\Knowledge\Source_Attachment_Meta_Service;
use WP_Post;

/**
 * Post Knowledge Generator using chunk-based approach.
 *
 * Generates knowledge entries from WordPress posts using:
 * 1. Content extraction and normalization
 * 2. Heading-aware chunking (400-600 tokens, 10-15% overlap)
 * 3. Chunk-based dataset entries
 *
 * @since 1.0.0
 */
class Post_Knowledge_Generator implements Knowledge_Generator_Interface {

	/**
	 * Content extractor for normalizing post content.
	 *
	 * @var Content_Extractor
	 */
	protected Content_Extractor $content_extractor;

	/**
	 * Heading-aware chunker for segmenting content.
	 *
	 * @var Heading_Aware_Chunker
	 */
	protected Heading_Aware_Chunker $chunker;

	/**
	 * Chunk entry builder for creating dataset entries.
	 *
	 * @var Chunk_Entry_Builder
	 */
	protected Chunk_Entry_Builder $entry_builder;

	/**
	 * Constructor to initialize dependencies.
	 *
	 * @since 1.0.0
	 */
	public function __construct() {
		$this->content_extractor = new Content_Extractor();
		$this->chunker           = new Heading_Aware_Chunker();
		$this->entry_builder     = new Chunk_Entry_Builder();
	}

	/**
	 * Generate knowledge entries from a dataset source.
	 *
	 * @param  Dataset  $dataset  Dataset to generate entries for.
	 *
	 * @return Dataset Updated dataset with generated entries.
	 * @throws Exception If generation fails.
	 * @since 1.0.0
	 */
	public function generate( Dataset $dataset ): Dataset {
		// Resolve source object
		$source = Helper::resolve_source_object( $dataset->get_source_type(), $dataset->get_source() );

		if ( ! $source instanceof WP_Post ) {
			throw new Exception( Error_Codes::VALIDATION_INVALID_VALUE,
				__( 'Source must be a WordPress post', 'limb-chatbot' ) );
		}

		// Step 1: Build content from selected native fields only.
		$content_config      = $this->get_content_config( $dataset );
		$native_field_keys   = $content_config[ Post_Content_Config_Service::NATIVE_FIELDS_KEY ] ?? [];
		$taxonomy_term_ids   = $content_config[ Post_Content_Config_Service::TAXONOMIES_KEY ] ?? [];
		$normalized_content  = $this->build_native_fields_content( $source, $native_field_keys );
		$taxonomy_content    = $this->build_selected_taxonomies_content( $source, $taxonomy_term_ids );
		$attachment_urls     = [];

		if ( '' !== trim( $taxonomy_content ) ) {
			if ( '' !== trim( $normalized_content ) ) {
				$normalized_content .= "\n\n---\n\n";
			}
			$normalized_content .= $taxonomy_content;
		}

		// Allow integrations to add additional custom fields content via filter
		$additional_custom_fields_content = apply_filters(
			'lbaic_dataset_entry_custom_fields_content',
			'',
			$source,
			$dataset
		);
		$attachment_urls = apply_filters(
			'lbaic_dataset_entry_custom_fields_attachment_urls',
			$attachment_urls,
			$source,
			$dataset
		);
		if ( ! empty( $additional_custom_fields_content ) ) {
			// Append additional custom fields content (plain text) with clear separation.
			if ( ! empty( $normalized_content ) ) {
				$normalized_content .= "\n\n---\n\n";
			}
			$normalized_content .= $additional_custom_fields_content;
		}

		// Allow integrations to add additional custom fields content via filter
		$additional_custom_fields_content = apply_filters(
			'lbaic_dataset_entry_custom_fields_content',
			'',
			$source,
			$dataset
		);
		if ( ! empty( $additional_custom_fields_content ) ) {
			// Append additional custom fields content (plain text) with clear separation
			$normalized_content .= "\n\n---\n\n" . $additional_custom_fields_content;
		}

		if ( empty( $normalized_content ) ) {
			$mapper    = ( new Knowledge_Mapper_Factory() )->make( $dataset->get_source_type() );
			$exception = new Exception( Error_Codes::EMPTY_VALUE,
				__( 'No content found in ', 'limb-chatbot' ),
				[ 'warning' => true ] );
			$exception->attach_link( get_permalink( $source->ID ), $mapper->get_entry_input( $source ) );
			throw $exception;
		}

		// Update the dataset meta to enable edition in future
		// Convert plain text custom fields to HTML paragraphs before cleaning
		$content_for_source = $this->convert_plain_text_fields_to_html( $normalized_content );
		$cleaned_content = $this->clean_html_for_source_content( $content_for_source );
		$dataset->update_meta( 'source_content', $cleaned_content );
		$this->update_attachment_meta( $dataset, $cleaned_content, $source, $native_field_keys, $attachment_urls );

		// Step 2: Chunk content with heading awareness
		$title  = $dataset->get_name();
		$chunks = $this->should_force_single_chunk( $native_field_keys )
			? $this->build_single_chunk( $normalized_content, $title )
			: $this->chunker->chunk( $normalized_content, $title );

		if ( empty( $chunks ) ) {
			throw new Exception( Error_Codes::EMPTY_VALUE,
				__( 'Failed to chunk content into segments', 'limb-chatbot' ) );
		}

		// Step 3: Process chunks into dataset entries
		$this->process_chunks( $dataset, $chunks, $source, $normalized_content, $title );

		return $dataset;
	}

	/**
	 * Force single chunk when post_content is not selected.
	 *
	 * @param  mixed  $native_fields
	 *
	 * @return bool
	 */
	private function should_force_single_chunk( $native_fields ): bool {
		if ( ! is_array( $native_fields ) ) {
			return true;
		}

		return ! in_array( 'post_content', $native_fields, true );
	}

	/**
	 * Build one chunk from the full normalized content.
	 *
	 * @param  string  $normalized_content
	 * @param  string  $title
	 *
	 * @return array
	 */
	private function build_single_chunk( string $normalized_content, string $title ): array {
		return [
			[
				'title'        => $title,
				'heading'      => $title,
				'heading_path' => $title,
				'content'      => $normalized_content,
				'tokens'       => null,
				'path_array'   => [],
				'metadata'     => [
					'heading_path' => $title,
				],
			],
		];
	}

	/**
	 * Read resolved content config from dataset metadata.
	 *
	 * @param  Dataset  $dataset
	 *
	 * @return array
	 * @since 1.0.0
	 */
	private function get_content_config( Dataset $dataset ): array {
		$content_config = $dataset->get_meta_value( Post_Content_Config_Service::CONFIG_KEY );

		if ( is_string( $content_config ) ) {
			$decoded = json_decode( $content_config, true );
			$content_config = is_array( $decoded ) ? $decoded : [];
		}

		return is_array( $content_config ) ? $content_config : [];
	}

	/**
	 * Build content text from selected native fields.
	 *
	 * @param  WP_Post  $post
	 * @param  mixed  $native_fields
	 *
	 * @return string
	 * @since 1.0.0
	 */
	private function build_native_fields_content( WP_Post $post, $native_fields ): string {
		if ( ! is_array( $native_fields ) || empty( $native_fields ) ) {
			return '';
		}

		$parts = [];

		foreach ( $native_fields as $field_key ) {
			if ( ! is_string( $field_key ) ) {
				continue;
			}

			switch ( $field_key ) {
				case 'post_title':
					$this->append_labeled_value( $parts, 'Post Title', $post->post_title );
					break;

				case 'post_content':
					$raw_content = strip_shortcodes( $post->post_content );
					$content     = $this->content_extractor->extract( $raw_content );
					if ( '' !== trim( $content ) ) {
						$parts[] = $content;
					}
					break;

				case 'post_excerpt':
					$this->append_labeled_value( $parts, 'Post Excerpt', wp_strip_all_tags( $post->post_excerpt ) );
					break;

				case 'post_date':
					$this->append_labeled_value( $parts, 'Post Date', get_the_date( 'c', $post ) );
					break;

				case 'author_name':
					$this->append_labeled_value(
						$parts,
						'Author Name',
						get_the_author_meta( 'display_name', (int) $post->post_author )
					);
					break;

				case 'permalink':
					$this->append_labeled_value( $parts, 'Post URL/Permalink', get_permalink( $post->ID ) );
					break;

				case 'featured_image':
					$featured_url = get_the_post_thumbnail_url( $post->ID, 'full' );
					if ( '' !== trim( (string) $featured_url ) ) {
						$parts[] = sprintf(
							'Featured Image: <img src="%s" alt="%s" />',
							esc_url( $featured_url ),
							esc_attr( get_the_title( $post->ID ) )
						);
					}
					break;
			}
		}

		return implode( "\n\n", $parts );
	}

	/**
	 * Add labeled field value to content parts.
	 *
	 * @param  array  $parts
	 * @param  string  $label
	 * @param  mixed  $value
	 *
	 * @return void
	 * @since 1.0.0
	 */
	private function append_labeled_value( array &$parts, string $label, $value ): void {
		if ( is_array( $value ) || is_object( $value ) ) {
			return;
		}

		$value = trim( (string) $value );

		if ( '' === $value ) {
			return;
		}

		$parts[] = sprintf( '%s: %s', $label, $value );
	}

	/**
	 * Build taxonomy label paths for selected term IDs related to the post.
	 *
	 * @param  WP_Post  $post
	 * @param  mixed  $selected_term_ids
	 *
	 * @return string
	 * @since 1.0.0
	 */
	private function build_selected_taxonomies_content( WP_Post $post, $selected_term_ids ): string {
		if ( ! is_array( $selected_term_ids ) || empty( $selected_term_ids ) ) {
			return '';
		}

		$selected_term_ids = array_values( array_filter( array_map( 'absint', $selected_term_ids ) ) );

		if ( empty( $selected_term_ids ) ) {
			return '';
		}

		$taxonomy_names = get_object_taxonomies( $post->post_type, 'names' );
		if ( empty( $taxonomy_names ) ) {
			return '';
		}

		$parts = [];

		foreach ( $taxonomy_names as $taxonomy_name ) {
			$terms = wp_get_post_terms( $post->ID, $taxonomy_name );
			if ( is_wp_error( $terms ) || empty( $terms ) ) {
				continue;
			}

			$matched_terms = array_values(
				array_filter(
					$terms,
					static function ( $term ) use ( $selected_term_ids ) {
						return in_array( (int) $term->term_id, $selected_term_ids, true );
					}
				)
			);

			if ( empty( $matched_terms ) ) {
				continue;
			}

			$taxonomy_object = get_taxonomy( $taxonomy_name );
			$taxonomy_label  = $taxonomy_object && ! empty( $taxonomy_object->label ) ? $taxonomy_object->label : $taxonomy_name;
			$paths           = array_map( function ( $term ) {
				return $this->get_term_label_path( (int) $term->term_id );
			}, $matched_terms );
			$paths           = array_values( array_filter( $paths ) );

			if ( empty( $paths ) ) {
				continue;
			}

			$parts[] = sprintf( '%s: %s', $taxonomy_label, implode( ', ', $paths ) );
		}

		return implode( "\n\n", $parts );
	}

	/**
	 * Build hierarchical label path for one term.
	 *
	 * Example: "News > Product Updates > Release Notes"
	 *
	 * @param  int  $term_id
	 *
	 * @return string
	 * @since 1.0.0
	 */
	private function get_term_label_path( int $term_id ): string {
		$term = get_term( $term_id );
		if ( is_wp_error( $term ) || ! $term ) {
			return '';
		}

		$path_labels = [ $term->name ];
		$parent_id   = (int) $term->parent;

		// Guard against malformed loops in term hierarchy.
		$guard = 0;
		while ( $parent_id > 0 && $guard < 20 ) {
			$parent_term = get_term( $parent_id, $term->taxonomy );

			if ( is_wp_error( $parent_term ) || ! $parent_term ) {
				break;
			}

			array_unshift( $path_labels, $parent_term->name );
			$parent_id = (int) $parent_term->parent;
			$guard ++;
		}

		return implode( ' > ', $path_labels );
	}

	/**
	 * Clean HTML content for source_content meta.
	 *
	 * Removes all attributes, styles, classes, and non-allowed tags.
	 * Keeps only clean textual HTML from WordPress WYSIWYG-supported elements.
	 *
	 * @param  string  $html  Raw HTML content.
	 * @return string Clean HTML with only allowed tags and no attributes.
	 * @since 1.0.0
	 */
	protected function clean_html_for_source_content( string $html ): string {
		// First, remove div tags but preserve their inner content
		// Handle nested divs by using a proper matching algorithm
		$html = $this->remove_div_tags_preserve_content( $html );

		// Define allowed tags for WordPress WYSIWYG editor
		$allowed_tags = [
			'a'      => [ 'href' => [] ],
			'p'      => [],
			'h1'     => [],
			'h2'     => [],
			'h3'     => [],
			'h4'     => [],
			'h5'     => [],
			'h6'     => [],
			'span'   => [],
			'strong' => [],
			'b'      => [],
			'em'     => [],
			'i'      => [],
			'img'    => [ 'src' => [], 'alt' => [] ],
			'table'  => [],
			'thead'  => [],
			'tbody'  => [],
			'tr'     => [],
			'td'     => [],
			'th'     => [],
			'ul'     => [],
			'ol'     => [],
			'li'     => [],
			'br'     => [],
		];

		// Use wp_kses to strip all attributes except those specified
		// IMPORTANT: wp_kses preserves ALL text content even when removing disallowed tags
		// It only removes the tags themselves, never the text inside them
		$cleaned = wp_kses( $html, $allowed_tags );

		// Clean up any remaining empty tags
		$cleaned = preg_replace( '/<(p|span|strong|b|em|i|a)[^>]*>\s*<\/\1>/is', '', $cleaned );

		// Normalize whitespace
		$cleaned = preg_replace( '/\s+/u', ' ', $cleaned );
		$cleaned = preg_replace( '/>\s+</', '><', $cleaned );

		// Restore spacing around block elements for readability
		$cleaned = preg_replace( '/><(h[1-6]|p|ul|ol|li|table|thead|tbody|tr)/i', '> <$1', $cleaned );
		$cleaned = preg_replace( '/(<\/h[1-6]|<\/p|<\/ul|<\/ol|<\/li|<\/table|<\/thead|<\/tbody|<\/tr)></i', '$1> <', $cleaned );

		return trim( $cleaned );
	}

	/**
	 * Persist image/file URL metadata collected from all sources.
	 *
	 * @param  Dataset  $dataset
	 * @param  string  $cleaned_source_content
	 * @param  WP_Post  $post
	 * @param  mixed  $native_field_keys
	 * @param  mixed  $custom_field_attachment_urls
	 *
	 * @return void
	 */
	private function update_attachment_meta(
		Dataset $dataset,
		string $cleaned_source_content,
		WP_Post $post,
		$native_field_keys,
		$custom_field_attachment_urls
	): void {
		$extra_urls = is_array( $custom_field_attachment_urls ) ? $custom_field_attachment_urls : [];

		if ( is_array( $native_field_keys ) && in_array( 'featured_image', $native_field_keys, true ) ) {
			$featured_url = get_the_post_thumbnail_url( $post->ID, 'full' );
			if ( is_string( $featured_url ) && '' !== trim( $featured_url ) ) {
				$extra_urls[] = $featured_url;
			}
		}

		( new Source_Attachment_Meta_Service() )->update_dataset_attachment_meta(
			$dataset,
			$cleaned_source_content,
			$extra_urls
		);
	}

	/**
	 * Extract image URLs from HTML img tags and markdown image syntax.
	 *
	 * @param  string  $content
	 *
	 * @return array
	 */
	private function extract_image_urls_from_content( string $content ): array {
		$urls = [];

		if ( preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\']/i', $content, $matches ) ) {
			$urls = array_merge( $urls, $matches[1] );
		}

		if ( preg_match_all( '/!\[[^\]]*\]\(([^)]+)\)/', $content, $markdown_matches ) ) {
			$urls = array_merge( $urls, $markdown_matches[1] );
		}

		return $this->normalize_url_list( $urls );
	}

	/**
	 * Normalize URL list to unique valid URLs.
	 *
	 * @param  array  $urls
	 *
	 * @return array
	 */
	private function normalize_url_list( array $urls ): array {
		$urls = array_map( 'trim', $urls );
		$urls = array_values( array_filter( $urls, static function ( $url ) {
			return is_string( $url ) && '' !== $url && false !== filter_var( $url, FILTER_VALIDATE_URL );
		} ) );

		return array_values( array_unique( $urls ) );
	}

	/**
	 * Determine if URL points to an image.
	 *
	 * @param  string  $url
	 *
	 * @return bool
	 */
	private function is_image_url( string $url ): bool {
		$path = (string) wp_parse_url( $url, PHP_URL_PATH );

		return (bool) preg_match( '/\.(jpg|jpeg|png|gif|webp|svg|bmp|avif)$/i', $path );
	}

	/**
	 * Remove div tags while preserving their inner content.
	 *
	 * Properly handles nested divs by iteratively processing from innermost to outermost.
	 *
	 * @param  string  $html  HTML content with div tags.
	 * @return string HTML with div tags removed but content preserved.
	 * @since 1.0.0
	 */
	private function remove_div_tags_preserve_content( string $html ): string {
		// Iterative approach: process from innermost divs outward
		// This handles nested divs correctly by removing them layer by layer
		$max_iterations = 50; // Safety limit
		$iteration = 0;
		
		while ( preg_match( '/<div[^>]*>/i', $html ) && $iteration < $max_iterations ) {
			$previous_html = $html;
			
			// Match div tags that don't contain other div tags (innermost first)
			// This regex matches a div that contains no other div tags
			// The negative lookahead ensures we match innermost divs first
			$html = preg_replace_callback(
				'/<div[^>]*>((?:(?!<div[^>]*>|<\/div>).)*?)<\/div>/is',
				function( $matches ) {
					$inner_content = $matches[1];
					
					// Preserve all content, even if it's just whitespace initially
					// We'll trim later, but want to ensure nothing is lost
					$inner_content = trim( $inner_content );
					
					// If inner content is empty, return nothing
					if ( empty( $inner_content ) ) {
						return '';
					}
					
					// If inner content already starts with a block element, return as-is
					// This preserves structure for headings, paragraphs, lists, tables
					if ( preg_match( '/^<(h[1-6]|p|ul|ol|table|thead|tbody|tr|li)/i', $inner_content ) ) {
						return $inner_content;
					}
					
					// Otherwise, wrap in paragraph to preserve structure
					// This ensures text content is not lost and has proper structure
					return '<p>' . $inner_content . '</p>';
				},
				$html
			);
			
			// If no change occurred, break to avoid infinite loop
			if ( $html === $previous_html ) {
				break;
			}
			
			$iteration++;
		}
		
		// Clean up any remaining orphaned div tags (unclosed or malformed)
		// Extract and preserve text content from any remaining div tags before removing them
		// This ensures no text is lost even from malformed HTML
		$html = preg_replace_callback(
			'/<div[^>]*>(.*)/is',
			function( $matches ) {
				// Extract all text content from the div, preserving structure
				$inner_content = $matches[1];
				// Remove any remaining closing div tags from the content
				$inner_content = preg_replace( '/<\/div>/i', '', $inner_content );
				$inner_content = trim( $inner_content );
				
				// If we have content, preserve it
				if ( ! empty( $inner_content ) ) {
					// If it doesn't start with a block element, wrap in paragraph
					if ( ! preg_match( '/^<(h[1-6]|p|ul|ol|table|thead|tbody|tr|li)/i', $inner_content ) ) {
						return '<p>' . $inner_content . '</p>';
					}
					return $inner_content;
				}
				return '';
			},
			$html
		);
		
		// Remove any remaining standalone div opening/closing tags
		$html = preg_replace( '/<div[^>]*>/i', '', $html );
		$html = preg_replace( '/<\/div>/i', '', $html );
		
		return $html;
	}

	/**
	 * Convert plain text fields to HTML paragraphs.
	 *
	 * Converts plain text lines (from custom fields) to HTML paragraphs
	 * while preserving existing HTML structure. Used for source_content meta.
	 *
	 * @param  string  $content  Mixed content (HTML and plain text).
	 *
	 * @return string Content with plain text lines wrapped in <p> tags.
	 * @since 1.0.0
	 */
	protected function convert_plain_text_fields_to_html( string $content ): string {
		// Split by double newlines (field separators)
		$sections = preg_split( '/\n\n+/', $content );
		$converted_sections = [];

		foreach ( $sections as $section ) {
			$section = trim( $section );
			if ( empty( $section ) ) {
				continue;
			}

			// Check if section already contains HTML tags
			if ( preg_match( '/<[^>]+>/', $section ) ) {
				// Already HTML, keep as is
				$converted_sections[] = $section;
			} else {
				// Plain text - split by single newlines and wrap each line in <p>
				$lines = explode( "\n", $section );
				$paragraphs = [];
				foreach ( $lines as $line ) {
					$line = trim( $line );
					if ( ! empty( $line ) ) {
						$paragraphs[] = '<p>' . esc_html( $line ) . '</p>';
					}
				}
				if ( ! empty( $paragraphs ) ) {
					$converted_sections[] = implode( "\n", $paragraphs );
				}
			}
		}

		return implode( "\n\n", $converted_sections );
	}

	/**
	 * Process chunks into dataset entries.
	 *
	 * Creates chunk-based entries and optionally generates QAs for specific post types.
	 *
	 * @param  Dataset  $dataset  Dataset to store entries.
	 * @param  array  $chunks  Array of chunk data.
	 * @param  WP_Post  $post  WordPress post object.
	 * @param  string  $normalized_content  Full normalized HTML content.
	 * @param  string  $title  Document title.
	 *
	 * @throws Exception If processing fails.
	 * @since 1.0.0
	 */
	protected function process_chunks(
		Dataset $dataset,
		array $chunks,
		WP_Post $post,
		string $normalized_content,
		string $title
	): void {
		$all_entries = new Collection();
		$source_url = get_permalink( $post->ID );
		$source_type = $dataset->get_source_type();

		// Build chunk-based entries with comprehensive metadata
		$chunk_index = 0;
		foreach ( $chunks as $chunk ) {
			try {
				// Enrich chunk with source metadata for better context
				$chunk['post_title'] = $title;
				$chunk['source_url'] = $source_url;
				$chunk['source_type'] = $source_type;
				$chunk['chunk_index'] = $chunk_index++;
				
				$entry = $this->entry_builder->build( $chunk );
				$entry->set_dataset_id( $dataset->get_id() );
				$all_entries->push_item( $entry );
			} catch ( \Exception $e ) {
				// Log individual entry build errors but don't fail the whole process
				Helper::log( [
					'error'      => 'Failed to build chunk entry',
					'dataset_id' => $dataset->get_id(),
					'chunk_data' => $chunk,
					'exception'  => $e->getMessage()
				], __METHOD__ );
			}
		}

		// Save all entries to the dataset
		if ( ! $all_entries->is_empty() ) {
			$all_entries->each( function ( Dataset_Entry $entry ) use ( $dataset ) {
				try {
					$entry->save();
				} catch ( \Exception $e ) {
					// Log individual entry save errors but don't fail the whole process
					Helper::log( [
						'error'      => 'Failed to save dataset entry',
						'dataset_id' => $dataset->get_id(),
						'entry_data' => $entry->get_entry(),
						'exception'  => $e->getMessage()
					], __METHOD__ );
				}
			} );
		} else {
			throw new Exception(
				Error_Codes::EMPTY_VALUE,
				__( 'No valid entries were generated from given source', 'limb-chatbot' )
			);
		}
	}
}