<?php
/**
 * llms.txt — a Markdown content manifest for AI / LLM crawlers
 * (Generative Engine Optimization). Served at /llms.txt and /llms-full.txt
 * when enabled.
 *
 * Uses an early, flush-free request interception (no rewrite rule needed) so it
 * works on locked-down multisite installs without flushing permalinks.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Llms {

    const CACHE_KEY = 'seoforge_llms_cache';

    public static function register( Seoforge_Loader $loader ) {
        $loader->add_action( 'init', __CLASS__, 'maybe_render', 1 );
        // Freshness: drop the cache when content changes or settings are saved.
        $loader->add_action( 'save_post', __CLASS__, 'clear_cache' );
        $loader->add_action( 'deleted_post', __CLASS__, 'clear_cache' );
    }

    public static function clear_cache() {
        delete_transient( self::CACHE_KEY );
        delete_transient( self::CACHE_KEY . '_full' );
    }

    public static function maybe_render() {
        if ( ! Seoforge_Settings::get( 'geo_llms_enabled', false ) ) {
            return;
        }
        $uri  = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
        $path = untrailingslashit( (string) wp_parse_url( $uri, PHP_URL_PATH ) );
        if ( '/llms.txt' !== $path && '/llms-full.txt' !== $path ) {
            return;
        }
        // Note: llms.txt is unique to this plugin (no competitor emits one), so
        // it is NOT gated behind should_stand_down() — it works even alongside
        // Yoast/RankMath/etc.
        $full = ( '/llms-full.txt' === $path );
        $key  = self::CACHE_KEY . ( $full ? '_full' : '' );

        $body = get_transient( $key );
        if ( false === $body ) {
            $body = self::build( $full );
            set_transient( $key, $body, 6 * HOUR_IN_SECONDS );
        }

        if ( ! headers_sent() ) {
            header( 'Content-Type: text/plain; charset=UTF-8' );
            header( 'X-Robots-Tag: noindex' );
        }
        echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plain-text manifest, all dynamic parts sanitised in build().
        exit;
    }

    /**
     * Build the Markdown manifest. Spec: an H1 site name, a blockquote summary,
     * then sections of links. llms-full.txt additionally inlines page bodies.
     */
    private static function build( $full ) {
        $name = wp_strip_all_tags( (string) get_bloginfo( 'name' ) );
        $desc = wp_strip_all_tags( (string) get_bloginfo( 'description' ) );

        $out  = '# ' . $name . "\n\n";
        if ( '' !== $desc ) {
            $out .= '> ' . $desc . "\n\n";
        }
        $out .= 'This file lists the main content of ' . home_url( '/' ) . " for AI assistants and answer engines.\n\n";

        $pages = self::query( 'page', $full ? 200 : 100 );
        if ( $pages ) {
            $out .= "## Pages\n\n";
            $out .= self::section( $pages, $full );
            $out .= "\n";
        }

        $posts = self::query( 'post', $full ? 100 : 50 );
        if ( $posts ) {
            $out .= "## Posts\n\n";
            $out .= self::section( $posts, $full );
            $out .= "\n";
        }

        $out .= '<!-- Generated by Brainwerk SEO Suite · ' . gmdate( 'Y-m-d' ) . " -->\n";
        return $out;
    }

    /** @return int[] post IDs */
    private static function query( $post_type, $limit ) {
        $q = new WP_Query( array(
            'post_type'              => $post_type,
            'post_status'            => 'publish',
            'posts_per_page'         => (int) $limit,
            'orderby'                => ( 'page' === $post_type ) ? 'menu_order title' : 'date',
            'order'                  => ( 'page' === $post_type ) ? 'ASC' : 'DESC',
            'no_found_rows'          => true,
            'ignore_sticky_posts'    => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
            'fields'                 => 'ids',
        ) );
        return $q->posts;
    }

    private static function section( $ids, $full ) {
        $lines = '';
        foreach ( $ids as $id ) {
            $title = wp_strip_all_tags( (string) get_the_title( $id ) );
            if ( '' === $title ) {
                continue;
            }
            $url     = get_permalink( $id );
            $excerpt = self::clean( (string) get_the_excerpt( $id ) );
            $lines  .= '- [' . $title . '](' . $url . ')';
            if ( '' !== $excerpt ) {
                $lines .= ': ' . ( $full ? $excerpt : self::clip( $excerpt, 200 ) );
            }
            $lines .= "\n";

            if ( $full ) {
                $content = self::clean( (string) apply_filters( 'the_content', get_post_field( 'post_content', $id ) ) );
                if ( '' !== $content ) {
                    $lines .= "\n  " . self::clip( $content, 4000 ) . "\n";
                }
            }
        }
        return $lines;
    }

    /** Normalise post text for the manifest: drop shortcodes, tags, entities. */
    private static function clean( $text ) {
        $text = strip_shortcodes( (string) $text );
        // strip_shortcodes only removes *registered* shortcodes; drop leftover
        // alpha-led shortcode tags too (e.g. theme shortcodes on an inactive
        // theme). Requires a letter start so footnote markers like [1] survive.
        $text = preg_replace( '/\[\/?[a-z][a-z0-9_-]*[^\]]*\]/i', '', (string) $text );
        $text = wp_strip_all_tags( $text );
        // Decode twice to also catch double-encoded source (&amp;#8220;).
        $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        return trim( preg_replace( '/\s+/', ' ', $text ) );
    }

    private static function clip( $text, $max ) {
        if ( strlen( $text ) <= $max ) {
            return $text;
        }
        return rtrim( mb_substr( $text, 0, $max ) ) . '…';
    }
}
