<?php
/**
 * llms.txt generator.
 *
 * Automatically generates and serves virtual /llms.txt and /llms-full.txt
 * files — the "robots.txt for AI" that helps LLMs understand site structure.
 *
 * @package MonitorCat\Modules\Seo\Generator
 */

declare(strict_types=1);

namespace MonitorCat\Modules\Seo\Generator;

defined( 'ABSPATH' ) || exit;

use MonitorCat\Settings;

/**
 * Generates and serves /llms.txt and /llms-full.txt via parse_request.
 */
final class LlmsTxtGenerator {

	/**
	 * Maximum number of pages to include.
	 *
	 * @var int
	 */
	private const MAX_PAGES = 20;

	/**
	 * Maximum number of posts to include.
	 *
	 * @var int
	 */
	private const MAX_POSTS = 20;

	/**
	 * Cache duration for generated content (24 hours).
	 *
	 * @var int
	 */
	private const CACHE_DURATION = DAY_IN_SECONDS;

	/**
	 * Plugin settings.
	 *
	 * @var Settings
	 */
	private Settings $settings;

	/**
	 * Constructor.
	 *
	 * @param Settings $settings Plugin settings.
	 */
	public function __construct( Settings $settings ) {
		$this->settings = $settings;
	}

	/**
	 * Register the parse_request hook.
	 */
	public function boot(): void {
		if ( ! $this->settings->get( 'seo.auto_llms_txt', true ) ) {
			return;
		}

		add_action( 'parse_request', array( $this, 'maybe_serve' ), 1 );
	}

	/**
	 * Check if the current request is for /llms.txt or /llms-full.txt and serve it.
	 */
	public function maybe_serve(): void {
		$request_uri = $_SERVER['REQUEST_URI'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
		$path        = strtok( $request_uri, '?' );

		$is_standard = '/llms.txt' === $path;
		$is_full     = '/llms-full.txt' === $path;

		if ( ! $is_standard && ! $is_full ) {
			return;
		}

		// Don't override a physical file.
		$filename = $is_full ? 'llms-full.txt' : 'llms.txt';
		if ( file_exists( ABSPATH . $filename ) ) {
			return;
		}

		$cache_key = 'monitorcat_' . str_replace( array( '.', '-' ), '_', $filename );
		$content   = get_transient( $cache_key );

		if ( false === $content ) {
			$content = $is_full ? $this->generate_full() : $this->generate();
			set_transient( $cache_key, $content, self::CACHE_DURATION );
		}

		header( 'Content-Type: text/plain; charset=utf-8' );
		header( 'X-Robots-Tag: noindex' );
		header( 'Cache-Control: public, max-age=86400' );
		echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plain text file.
		exit;
	}

	/**
	 * Generate the standard llms.txt content in Markdown format.
	 *
	 * The standard version includes a site overview, main pages,
	 * categories, and recent posts with short descriptions.
	 *
	 * @return string The Markdown content.
	 */
	public function generate(): string {
		$site_name = get_bloginfo( 'name' );
		$site_desc = get_bloginfo( 'description' );
		$home_url  = home_url( '/' );

		$output  = '# ' . $site_name . "\n";
		$output .= '> ' . $site_desc . "\n\n";

		// Homepage.
		$output .= '## Homepage' . "\n";
		$output .= '- [' . $site_name . '](' . $home_url . '): ' . $site_desc . "\n\n";

		// Published pages.
		$output .= $this->generate_pages_section( false );

		// Categories.
		$output .= $this->generate_categories_section();

		// Recent posts.
		$output .= $this->generate_posts_section( false );

		return $output;
	}

	/**
	 * Generate the extended llms-full.txt content.
	 *
	 * The full version includes everything from standard plus
	 * longer descriptions and post content excerpts.
	 *
	 * @return string The Markdown content.
	 */
	public function generate_full(): string {
		$site_name = get_bloginfo( 'name' );
		$site_desc = get_bloginfo( 'description' );
		$home_url  = home_url( '/' );

		$output  = '# ' . $site_name . "\n";
		$output .= '> ' . $site_desc . "\n\n";

		// Homepage.
		$output .= '## Homepage' . "\n";
		$output .= '- [' . $site_name . '](' . $home_url . '): ' . $site_desc . "\n\n";

		// Published pages (with extended descriptions).
		$output .= $this->generate_pages_section( true );

		// Categories with descriptions.
		$output .= $this->generate_categories_section();

		// Recent posts (with extended descriptions).
		$output .= $this->generate_posts_section( true );

		return $output;
	}

	/**
	 * Generate the pages section.
	 *
	 * @param bool $extended Whether to include longer descriptions.
	 * @return string The section content.
	 */
	private function generate_pages_section( bool $extended ): string {
		$pages = get_posts(
			array(
				'post_type'      => 'page',
				'post_status'    => 'publish',
				'posts_per_page' => self::MAX_PAGES,
				'orderby'        => 'menu_order',
				'order'          => 'ASC',
			)
		);

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

		$output = '## Main Pages' . "\n";
		foreach ( $pages as $page ) {
			$title   = get_the_title( $page->ID );
			$url     = get_permalink( $page->ID );
			$desc    = $this->get_short_description( $page, $extended ? 300 : 160 );
			$output .= '- [' . $title . '](' . $url . ')';
			if ( ! empty( $desc ) ) {
				$output .= ': ' . $desc;
			}
			$output .= "\n";
		}
		$output .= "\n";

		return $output;
	}

	/**
	 * Generate the categories section.
	 *
	 * @return string The section content.
	 */
	private function generate_categories_section(): string {
		$categories = get_categories(
			array(
				'hide_empty' => true,
				'orderby'    => 'count',
				'order'      => 'DESC',
				'number'     => 15,
			)
		);

		if ( empty( $categories ) || is_wp_error( $categories ) ) {
			return '';
		}

		// Filter out the default "Uncategorized" if it's the only category.
		$default_cat_id = (int) get_option( 'default_category' );
		$filtered       = array_filter(
			$categories,
			function ( $cat ) use ( $default_cat_id ) {
				return $cat->term_id !== $default_cat_id || $cat->count > 0;
			}
		);

		if ( count( $filtered ) <= 1 && count( $categories ) <= 1 ) {
			return '';
		}

		$output = '## Categories' . "\n";
		foreach ( $categories as $category ) {
			$url     = get_category_link( $category->term_id );
			$name    = $category->name;
			$count   = $category->count;
			$output .= '- [' . $name . '](' . $url . ')';
			if ( ! empty( $category->description ) ) {
				$output .= ': ' . $category->description;
			}
			/* translators: %d: number of posts */
			$output .= ' (' . sprintf( _n( '%d post', '%d posts', $count, 'monitor-cat' ), $count ) . ')';
			$output .= "\n";
		}
		$output .= "\n";

		return $output;
	}

	/**
	 * Generate the posts section.
	 *
	 * @param bool $extended Whether to include longer descriptions.
	 * @return string The section content.
	 */
	private function generate_posts_section( bool $extended ): string {
		$posts = get_posts(
			array(
				'post_type'      => 'post',
				'post_status'    => 'publish',
				'posts_per_page' => self::MAX_POSTS,
				'orderby'        => 'date',
				'order'          => 'DESC',
			)
		);

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

		$output = '## Recent Posts' . "\n";
		foreach ( $posts as $post ) {
			$title   = get_the_title( $post->ID );
			$url     = get_permalink( $post->ID );
			$desc    = $this->get_short_description( $post, $extended ? 300 : 160 );
			$output .= '- [' . $title . '](' . $url . ')';
			if ( ! empty( $desc ) ) {
				$output .= ': ' . $desc;
			}
			$output .= "\n";
		}

		return $output;
	}

	/**
	 * Get a short description from excerpt or content.
	 *
	 * @param object $post      The post object.
	 * @param int    $max_length Maximum character length (default 160).
	 * @return string Description trimmed to max_length.
	 */
	private function get_short_description( object $post, int $max_length = 160 ): string {
		$text = '';

		if ( ! empty( $post->post_excerpt ) ) {
			$text = $post->post_excerpt;
		} else {
			$content = $post->post_content ?? '';
			$text    = wp_strip_all_tags( $content );
		}

		$text = trim( preg_replace( '/\s+/', ' ', $text ) ?? $text );

		if ( mb_strlen( $text ) > $max_length ) {
			$text = mb_substr( $text, 0, $max_length - 3 ) . '...';
		}

		return $text;
	}
}
