<?php

namespace StaticDeploy;

use GuzzleHttp;
use GuzzleHttp\Psr7\Utils as Psr7Utils;
use SimpleXMLElement;

/**
 * SitemapParser class
 *
 * Originally forked from https://github.com/VIPnytt/SitemapParser
 *
 * Specifications:
 *
 * @link http://www.sitemaps.org/protocol.html
 */
class SitemapParser {

    use URLParser;

    /**
     * Default User-Agent
     */
    const DEFAULT_USER_AGENT = 'staticdeploy.com';

    /**
     * XML file extension
     */
    const XML_EXTENSION = 'xml';

    /**
     * Compressed XML file extension
     */
    const XML_EXTENSION_COMPRESSED = 'xml.gz';

    /**
     * XML Sitemap tag
     */
    const XML_TAG_SITEMAP = 'sitemap';

    /**
     * XML URL tag
     */
    const XML_TAG_URL = 'url';

    /**
     * Robots.txt path
     */
    const ROBOTSTXT_PATH = '/robots.txt';

    /**
     * Sitemaps discovered
     *
     * @var mixed[]
     */
    protected $sitemaps = [];

    /**
     * URLs discovered
     *
     * @var mixed[]
     */
    protected $urls = [];

    /**
     * Sitemap URLs discovered but not yet parsed
     *
     * @var mixed[]
     */
    protected $queue = [];

    /**
     * Parsed URLs history
     *
     * @var mixed[]
     */
    protected $history = [];

    /**
     * Current URL being parsed
     *
     * @var null|string
     */
    protected $current_url;

    /**
     * Constructor
     *
     * @param string $user_agent User-Agent to send with every HTTP(S) request
     * @param mixed[] $config Configuration options
     */
    public function __construct(
        /**
         * User-Agent to send with every HTTP(S) request
         */
        protected $user_agent = self::DEFAULT_USER_AGENT,
        /**
         * Configuration options
         */
        protected array $config = []
    ) {
    }

    /**
     * Parse Recursive
     *
     * @param string $url
     */
    public function parseRecursive( $url ): void {
        $this->addToQueue( [ $url ] );
        $todo = $this->getQueue();
        $ct = count( $todo );

        while ( $ct > 0 ) {
            $sitemaps = $this->sitemaps;
            $urls = $this->urls;

            try {
                $this->parse( strval( array_shift( $todo ) ) );
            } catch ( StaticDeployException $e ) {
                WsLog::w( $e->getMessage() );
                // Keep crawling
                continue;
            }

            $this->sitemaps = array_merge_recursive( $sitemaps, $this->sitemaps );
            $this->urls = array_merge_recursive( $urls, $this->urls );

            $todo = $this->getQueue();
            $ct = count( $todo );
        }
    }

    /**
     * Add an array of URLs to the parser queue
     *
     * @param mixed[] $url_array
     */
    public function addToQueue( array $url_array ): void {
        foreach ( $url_array as $url ) {
            $url = $this->urlEncode( strval( $url ) );
            if ( $this->urlValidate( $url ) ) {
                $this->queue[] = $url;
            }
        }
    }

    /**
     * Sitemap URLs discovered but not yet parsed
     *
     * @return mixed[]
     */
    public function getQueue() {
        $this->queue = array_values(
            array_diff(
                array_unique(
                    array_merge( $this->queue, array_keys( $this->sitemaps ) )
                ),
                $this->history
            )
        );
        return $this->queue;
    }

    /**
     * Parse
     *
     * @param string $url URL to parse
     * @param string|null $url_content URL body content (provide to skip download)
     * @throws StaticDeployException
     */
    public function parse( $url, $url_content = null ): void {
        $this->clean();
        $this->current_url = $this->urlEncode( $url );
        if ( ! $this->urlValidate( $this->current_url ) ) {
            throw WsLog::ex( 'Invalid URL' );
        }
        $this->history[] = $this->current_url;
        $response = is_string( $url_content ) ? $url_content : $this->getContent();

        if ( ! $response ) {
            return;
        }

        // Check if content is an gzip file
        if ( str_starts_with( $response, "\x1f\x8b\x08" ) ) {
            $response = gzdecode( $response );
        }

        if ( ! is_string( $response ) ) {
            return;
        }

        $sitemap_json = $this->generateXMLObject( $response );

        if ( ! $sitemap_json instanceof SimpleXMLElement ) {
            $this->parseString( $response );
            return;
        }

        $this->parseJson( self::XML_TAG_SITEMAP, $sitemap_json );
        $this->parseJson( self::XML_TAG_URL, $sitemap_json );
    }

    /**
     * Cleanup between each parse
     *
     * @return void
     */
    protected function clean() {
        $this->sitemaps = [];
        $this->urls = [];
    }

    /**
     * Request the body content of an URL
     *
     * @return ?string Raw body content
     * @throws StaticDeployException
     */
    protected function getContent(): ?string {
        $this->current_url =
            $this->urlEncode( (string) $this->current_url );

        if ( ! $this->urlValidate( $this->current_url ) ) {
            throw WsLog::ex( 'Invalid URL' );
        }

        try {
            if ( ! isset( $this->config['guzzle']['headers']['User-Agent'] ) ) {
                $this->config['guzzle']['headers']['User-Agent'] = $this->user_agent;
            }
            $client = new GuzzleHttp\Client( [ 'verify' => false ] );

            if ( ! is_array( $this->config['guzzle'] ) ) {
                WsLog::w( 'Guzzle config is not in expected array format' );
                return null;
            }
            $res = $client->request( 'GET', $this->current_url, $this->config['guzzle'] );
            if ( $res->getStatusCode() === 200 ) {
                return $res->getBody()->getContents();
            }
            WsLog::w(
                'Got ' . $res->getStatusCode() .
                ' for sitemap url "' . $this->current_url . '", skipping.'
            );
            return null;
        } catch ( GuzzleHttp\Exception\TransferException ) {
            // We can't use the parent exception due to
            // https://github.com/WordPress/WordPress-Coding-Standards/issues/2447
            throw WsLog::ex( 'Unable to fetch URL contents' );
        } catch ( GuzzleHttp\Exception\GuzzleException ) {
            // We can't use the parent exception due to
            // https://github.com/WordPress/WordPress-Coding-Standards/issues/2447
            throw WsLog::ex( 'GuzzleHttp exception' );
        }
    }

    /**
     * Search for sitemaps in the robots.txt content
     *
     * @return \Iterator<string>
     */
    public function parseRobotstxt( string $robotstxt ): \Iterator {
        // Split lines into array
        $lines = array_filter(
            array_map(
                fn ( $line ): string => trim( (string) $line ),
                (array) preg_split( '/\r\n|\n|\r/', $robotstxt )
            )
        );

        // Parse each line individually
        foreach ( $lines as $line ) {
            // Remove comments
            $line = preg_split( '/#/', $line, 2 );

            if ( ! $line ) {
                continue;
            }

            $line = $line[0];

            // Split by directive and rule
            $pair = array_map(
                fn ( $line ): string => trim( (string) $line ),
                (array) preg_split( '/:/', $line, 2 )
            );
            // Check if the line contains a sitemap
            if ( strtolower( $pair[0] ) !== self::XML_TAG_SITEMAP ) {
                // Directive is not supported
                continue;
            }
            if ( empty( $pair[1] ) ) {
                // Line does not contain any directive
                continue;
            }
            $url = $this->urlEncode( $pair[1] );
            if ( $this->urlValidate( $url ) ) {
                yield $url;
            } else {
                WsLog::l(
                    "Invalid sitemap URL in robots.txt: {$url}"
                    . '\nEnsure that it is a valid absolute URL.'
                );
            }
        }
    }

    /**
     * Validate URL arrays and add them to their corresponding arrays
     *
     * @param string $type sitemap|url
     * @param array<string, string> $arr Tag array
     */
    protected function addArray( $type, array $arr ): bool {
        if ( ! isset( $arr['loc'] ) ) {
            return false;
        }
        $arr['loc'] = $this->urlEncode( trim( strval( $arr['loc'] ) ) );
        if ( $this->urlValidate( $arr['loc'] ) ) {
            switch ( $type ) {
                case self::XML_TAG_SITEMAP:
                    $this->sitemaps[ $arr['loc'] ] =
                        $this->fixMissingTags( [ 'lastmod' ], $arr );
                    return true;
                case self::XML_TAG_URL:
                    $this->urls[ $arr['loc'] ] = $this->fixMissingTags(
                        [ 'lastmod', 'changefreq', 'priority' ],
                        $arr
                    );
                    return true;
            }
        }
        return false;
    }

    /**
     * Check for missing values and set them to null
     *
     * @param string[] $tags Tags check if exists
     * @param array<string, mixed> $arr Array to check
     * @return mixed[]
     */
    protected function fixMissingTags( array $tags, array $arr ): array {
        foreach ( $tags as $tag ) {
            if ( empty( $arr[ $tag ] ) ) {
                $arr[ $tag ] = null;
            }
        }
        return $arr;
    }

    /**
     * Generate the \SimpleXMLElement object if the XML is valid
     *
     * @param string $xml
     * @throws StaticDeployException
     */
    protected function generateXMLObject( $xml ): \SimpleXMLElement|false {
        // strip XML comments from files
        // if they occur at the beginning of the file it will invalidate the XML
        // this occurs with certain versions of Yoast
        $xml = preg_replace(
            '/\s*\<\!\-\-((?!\-\-\>)[\s\S])*\-\-\>\s*/',
            '',
            (string) $xml
        );
        try {
            libxml_use_internal_errors( true );
            return new SimpleXMLElement( (string) $xml, LIBXML_NOCDATA );
            // TODO: ignoring until refactor as only generic exception
            // thrown and abuses try/catch as conditional
            // @phpstan-ignore-next-line
        } catch ( \Exception ) {
            return false;
        }
    }

    /**
     * Parse line separated text string
     *
     * @param string $str
     */
    protected function parseString( $str ): bool {
        if ( ! isset( $this->config['strict'] ) || $this->config['strict'] !== false ) {
            // Strings are not part of any documented sitemap standard
            return false;
        }
        $array = array_filter(
            array_map(
                fn ( $line ): string => trim( (string) $line ),
                (array) preg_split( '/\r\n|\n|\r/', $str )
            )
        );
        foreach ( $array as $line ) {
            if ( $this->isSitemapURL( $line ) ) {
                $this->addArray( self::XML_TAG_SITEMAP, [ 'loc' => $line ] );
                continue;
            }
            $this->addArray( self::XML_TAG_URL, [ 'loc' => $line ] );
        }
        return true;
    }

    /**
     * Check if the URL may contain an Sitemap
     *
     * @param string $url
     */
    protected function isSitemapURL( $url ): bool {
        $path = Psr7Utils::uriFor( $this->urlEncode( $url ) )->getPath();
        return $this->urlValidate( $url ) && (
                substr(
                    $path,
                    -strlen( self::XML_EXTENSION ) - 1
                ) === '.' . self::XML_EXTENSION ||
                substr(
                    $path,
                    -strlen( self::XML_EXTENSION_COMPRESSED ) - 1
                ) === '.' . self::XML_EXTENSION_COMPRESSED
            );
    }

    /**
     * Parse Json object
     *
     * @param string $type Sitemap or URL
     * @param \SimpleXMLElement $json object
     */
    protected function parseJson( $type, \SimpleXMLElement $json ): bool {
        if ( ! isset( $json->$type ) ) {
            return false;
        }
        foreach ( $json->$type as $url ) {
            $this->addArray( $type, (array) $url );
        }
        return true;
    }

    /**
     * Sitemaps discovered
     *
     * @return mixed[]
     */
    public function getSitemaps() {
        return $this->sitemaps;
    }

    /**
     * URLs discovered
     *
     * @return mixed[]
     */
    public function getURLs() {
        return $this->urls;
    }
}
