<?php

namespace GOSP\GravityOps\Core\SuiteCore\Admin;

use DOMDocument;
use DOMElement;
use DOMNode;
use FilesystemIterator;
use GOSP\League\CommonMark\GithubFlavoredMarkdownConverter;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Renders bundled Markdown documentation inside the GravityOps admin shell.
 */
class DocsRenderer {

	/**
	 * Absolute path to a plugin's docs directory.
	 *
	 * @var string
	 */
	private string $docs_path;

	/**
	 * Registered page slug.
	 *
	 * @var string
	 */
	private string $page_slug;

	/**
	 * Capability required to view these docs.
	 *
	 * @var string
	 */
	private string $capability;

	/**
	 * Converter instance.
	 *
	 * @var GithubFlavoredMarkdownConverter|null
	 */
	private ?GithubFlavoredMarkdownConverter $converter = null;

	/**
	 * Constructor.
	 *
	 * @param string $docs_path Absolute path to the docs directory.
	 * @param string $page_slug Registered shell page slug.
	 * @param string $capability Capability required to view the docs.
	 */
	public function __construct( string $docs_path, string $page_slug = '', string $capability = 'manage_options' ) {
		$this->docs_path  = untrailingslashit( $docs_path );
		$this->page_slug  = $page_slug;
		$this->capability = $capability;
	}

	/**
	 * Render the complete docs tab.
	 *
	 * @return void
	 */
	public function render(): void {
		if ( ! class_exists( GithubFlavoredMarkdownConverter::class ) ) {
			echo '<div class="gops-card"><h2 class="gops-title">Docs unavailable</h2><p>The Markdown renderer dependency is not installed.</p></div>';
			return;
		}

		$categories = $this->get_index();
		if ( empty( $categories ) ) {
			echo '<div class="gops-card"><h2 class="gops-title">Docs</h2><p>No Markdown docs were found for this plugin.</p></div>';
			return;
		}

		$first_article = $categories[0]['articles'][0] ?? null;
		if ( ! $first_article ) {
			echo '<div class="gops-card"><h2 class="gops-title">Docs</h2><p>No Markdown docs were found for this plugin.</p></div>';
			return;
		}

		$article_count = $this->count_articles( $categories );
		$initial       = $this->get_article( $first_article['path'] );
		if ( empty( $initial ) ) {
			echo '<div class="gops-card"><h2 class="gops-title">Docs</h2><p>The first Markdown doc could not be loaded.</p></div>';
			return;
		}

		echo '<div class="gops-docs' . ( 1 === $article_count ? ' gops-docs--single' : '' ) . '" data-gops-docs-page="' . esc_attr( $this->get_current_page_slug() ) . '" data-gops-docs-token="' . esc_attr( $this->create_ajax_token() ) . '">';
		if ( $article_count > 1 ) {
			echo '<aside class="gops-docs__nav" aria-label="Documentation sections">';
			foreach ( $categories as $category ) {
				// Root-level docs belong to no category, so they render as loose links with no
				// collapsible heading and are always expanded.
				if ( '' === $category['label'] ) {
					echo '<div class="gops-docs__nav-group">';
					echo '<ul class="gops-docs__nav-list is-open">';
					$this->render_nav_links( $category['articles'], $first_article['path'] );
					echo '</ul>';
					echo '</div>';
					continue;
				}

				$is_open = $this->category_contains_path( $category, $first_article['path'] );
				$list_id = 'gops-docs-nav-' . sanitize_title( $category['label'] );
				echo '<div class="gops-docs__nav-group">';
				echo '<button type="button" class="gops-docs__nav-title" aria-expanded="' . esc_attr( $is_open ? 'true' : 'false' ) . '" aria-controls="' . esc_attr( $list_id ) . '">';
				echo '<span>' . esc_html( $category['label'] ) . '</span>';
				echo '<span class="gops-docs__nav-count">' . esc_html( (string) count( $category['articles'] ) ) . '</span>';
				echo '</button>';
				echo '<ul id="' . esc_attr( $list_id ) . '" class="gops-docs__nav-list' . ( $is_open ? ' is-open' : '' ) . '">';
				$this->render_nav_links( $category['articles'], $first_article['path'] );
				echo '</ul>';
				echo '</div>';
			}
			echo '</aside>';
		}

		$this->render_article_region( $initial );
		echo '</div>';
	}

	/**
	 * Return a single rendered article.
	 *
	 * @param string $relative_path Markdown path relative to docs root.
	 * @return array{id:string,title:string,category:string,path:string,html:string,toc:array<int,array{id:string,title:string,level:int}>}|null
	 */
	public function get_article( string $relative_path ): ?array {
		if ( ! class_exists( GithubFlavoredMarkdownConverter::class ) ) {
			return null;
		}

		$files = $this->get_markdown_files();
		if ( empty( $files[ $relative_path ] ) ) {
			return null;
		}

		return $this->build_article( $relative_path, $files[ $relative_path ] );
	}

	/**
	 * Build sorted category and article data from Markdown files.
	 *
	 * Each subdirectory of the docs root becomes a labelled group. Docs that sit directly in the
	 * docs root have no category, so they are returned with an empty label and rendered as loose
	 * links; consecutive root-level docs share one label-less entry. Groups and loose entries stay
	 * in natural path order, so numeric filename prefixes drive the final sidebar order.
	 *
	 * @return array<int,array{label:string,articles:array<int,array{id:string,title:string,path:string}>}>
	 */
	public function get_index(): array {
		$files = $this->get_markdown_files();
		if ( empty( $files ) ) {
			return [];
		}

		$categories    = [];
		$group_indexes = []; // Subdirectory slug => index in $categories.
		$loose_index   = null; // Index of the label-less entry the previous root-level doc joined.

		foreach ( $files as $relative_path => $file_path ) {
			$markdown = (string) file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			$article  = [
				'id'    => 'gops-doc-' . sanitize_title( $relative_path ),
				'title' => $this->get_article_title( $markdown, basename( $relative_path, '.md' ) ),
				'path'  => $relative_path,
			];

			$parts = explode( '/', $relative_path );
			if ( count( $parts ) > 1 ) {
				$group_key = $parts[0];
				if ( ! isset( $group_indexes[ $group_key ] ) ) {
					$group_indexes[ $group_key ] = count( $categories );
					$categories[]                = [
						'label'    => $this->label_from_slug( $group_key ),
						'articles' => [],
					];
				}

				$categories[ $group_indexes[ $group_key ] ]['articles'][] = $article;
				$loose_index = null;
				continue;
			}

			if ( null === $loose_index ) {
				$loose_index  = count( $categories );
				$categories[] = [
					'label'    => '',
					'articles' => [],
				];
			}

			$categories[ $loose_index ]['articles'][] = $article;
		}

		return $categories;
	}

	/**
	 * Build a rendered article payload from a Markdown file.
	 *
	 * @param string $relative_path Markdown path relative to docs root.
	 * @param string $file_path Absolute file path.
	 * @return array{id:string,title:string,category:string,path:string,html:string,toc:array<int,array{id:string,title:string,level:int}>}
	 */
	private function build_article( string $relative_path, string $file_path ): array {
		$markdown = (string) file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$parts    = explode( '/', $relative_path );
		$rendered = $this->render_markdown_with_toc( $this->remove_first_h1( $markdown ) );

		return [
			'id'       => 'gops-doc-' . sanitize_title( $relative_path ),
			'title'    => $this->get_article_title( $markdown, basename( $relative_path, '.md' ) ),
			'category' => count( $parts ) > 1 ? $this->label_from_slug( $parts[0] ) : '',
			'path'     => $relative_path,
			'html'     => $rendered['html'],
			'toc'      => $rendered['toc'],
		];
	}

	/**
	 * Render the sidebar links for one group of articles.
	 *
	 * @param array<int,array{id:string,title:string,path:string}> $articles Articles in sidebar order.
	 * @param string                                               $active_path Path of the active doc.
	 * @return void
	 */
	private function render_nav_links( array $articles, string $active_path ): void {
		foreach ( $articles as $article ) {
			$is_active = $article['path'] === $active_path;
			echo '<li><button type="button" class="gops-docs__nav-link' . ( $is_active ? ' is-active' : '' ) . '" data-gops-doc-id="' . esc_attr( $article['id'] ) . '" data-gops-doc-path="' . esc_attr( $article['path'] ) . '" aria-current="' . esc_attr( $is_active ? 'page' : 'false' ) . '">';
			echo '<span class="gops-docs__nav-link-title">' . esc_html( $article['title'] ) . '</span>';
			echo '</button></li>';
		}
	}

	/**
	 * Render the article content and its right-side table of contents.
	 *
	 * @param array{id:string,title:string,category:string,path:string,html:string,toc:array<int,array{id:string,title:string,level:int}>} $article Rendered article payload.
	 * @return void
	 */
	private function render_article_region( array $article ): void {
		echo '<div class="gops-docs__article-region">';
		echo '<div class="gops-docs__article-wrap" aria-live="polite">';
		$this->render_article_shell( $article );
		echo '</div>';
		$this->render_article_toc( $article['toc'] );
		echo '</div>';
	}

	/**
	 * Render the active article shell.
	 *
	 * @param array{id:string,title:string,category:string,path:string,html:string,toc:array<int,array{id:string,title:string,level:int}>} $article Rendered article payload.
	 * @return void
	 */
	private function render_article_shell( array $article ): void {
		echo '<article id="' . esc_attr( $article['id'] ) . '" class="gops-docs__article" data-gops-doc-active="' . esc_attr( $article['path'] ) . '">';
		echo '<header class="gops-docs__article-header">';
		if ( '' !== $article['category'] ) {
			echo '<span class="gops-docs__category-kicker">' . esc_html( $article['category'] ) . '</span>';
		}
		echo '<h2 class="gops-docs__article-title">' . esc_html( $article['title'] ) . '</h2>';
		echo '</header>';
		echo '<div class="gops-docs__body">';
		echo $article['html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo '</div>';
		echo '</article>';
	}

	/**
	 * Render right-side article table of contents.
	 *
	 * @param array<int,array{id:string,title:string,level:int}> $toc Article heading list.
	 * @return void
	 */
	private function render_article_toc( array $toc ): void {
		if ( empty( $toc ) ) {
			echo '<aside class="gops-docs__toc is-empty" aria-label="On this page"></aside>';
			return;
		}

		echo '<aside class="gops-docs__toc" aria-label="On this page">';
		echo '<h2 class="gops-docs__toc-title">On this page</h2>';
		echo '<ul class="gops-docs__toc-list">';
		foreach ( $toc as $item ) {
			echo '<li class="gops-docs__toc-item gops-docs__toc-item--level-' . esc_attr( (string) $item['level'] ) . '">';
			echo '<a href="#' . esc_attr( $item['id'] ) . '">' . esc_html( $item['title'] ) . '</a>';
			echo '</li>';
		}
		echo '</ul>';
		echo '</aside>';
	}

	/**
	 * Count articles in the docs index.
	 *
	 * @param array<int,array{label:string,articles:array<int,array{id:string,title:string,path:string}>}> $categories Docs index.
	 * @return int
	 */
	private function count_articles( array $categories ): int {
		$count = 0;
		foreach ( $categories as $category ) {
			$count += count( $category['articles'] );
		}

		return $count;
	}

	/**
	 * Determine whether a category contains the active doc path.
	 *
	 * @param array{label:string,articles:array<int,array{id:string,title:string,path:string}>} $category Category data. An empty label means the articles sit in the docs root.
	 * @param string                                                                            $path Active doc path.
	 * @return bool
	 */
	private function category_contains_path( array $category, string $path ): bool {
		foreach ( $category['articles'] as $article ) {
			if ( $article['path'] === $path ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Resolve the current shell page slug for AJAX requests.
	 *
	 * @return string
	 */
	private function get_current_page_slug(): string {
		if ( '' !== $this->page_slug ) {
			return $this->page_slug;
		}

		return isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
	}

	/**
	 * Store docs AJAX context in a short-lived token.
	 *
	 * @return string Token key.
	 */
	private function create_ajax_token(): string {
		$token = wp_generate_uuid4();
		set_transient(
			'gops_docs_' . $token,
			[
				'docs_path'  => $this->docs_path,
				'capability' => $this->capability,
			],
			HOUR_IN_SECONDS
		);

		return $token;
	}

	/**
	 * Find Markdown files sorted by natural relative path.
	 *
	 * @return array<string,string> Relative path => absolute path.
	 */
	private function get_markdown_files(): array {
		$root = realpath( $this->docs_path );
		if ( false === $root || ! is_dir( $root ) ) {
			return [];
		}

		$files    = [];
		$iterator = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS )
		);

		foreach ( $iterator as $file ) {
			if ( ! $file instanceof SplFileInfo || ! $file->isFile() || 'md' !== strtolower( $file->getExtension() ) ) {
				continue;
			}

			$relative_path           = ltrim( str_replace( [ $root, DIRECTORY_SEPARATOR ], [ '', '/' ], $file->getPathname() ), '/' );
			$files[ $relative_path ] = $file->getPathname();
		}

		uksort( $files, 'strnatcasecmp' );

		return $files;
	}

	/**
	 * Render Markdown through CommonMark, sanitize it, and collect headings for a TOC.
	 *
	 * @param string $markdown Markdown content.
	 * @return array{html:string,toc:array<int,array{id:string,title:string,level:int}>}
	 */
	private function render_markdown_with_toc( string $markdown ): array {
		if ( null === $this->converter ) {
			$this->converter = new GithubFlavoredMarkdownConverter(
				[
					'html_input'         => 'strip',
					'allow_unsafe_links' => false,
				]
			);
		}

		$html      = (string) $this->converter->convert( $markdown );
		$sanitized = wp_kses( $html, $this->get_allowed_html() );

		return $this->add_heading_ids_and_toc( $sanitized );
	}

	/**
	 * Add stable IDs to headings, configure article links, and build an article TOC.
	 *
	 * @param string $html Sanitized HTML.
	 * @return array{html:string,toc:array<int,array{id:string,title:string,level:int}>}
	 */
	private function add_heading_ids_and_toc( string $html ): array {
		if ( ! class_exists( 'DOMDocument' ) ) {
			return [
				'html' => $html,
				'toc'  => [],
			];
		}

		$document = new DOMDocument( '1.0', 'UTF-8' );
		$previous = libxml_use_internal_errors( true );
		$document->loadHTML( '<?xml encoding="UTF-8"><!DOCTYPE html><html><body><div id="gops-doc-fragment">' . $html . '</div></body></html>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
		libxml_clear_errors();
		libxml_use_internal_errors( $previous );

		$wrapper = $document->getElementById( 'gops-doc-fragment' );
		if ( ! $wrapper ) {
			return [
				'html' => $html,
				'toc'  => [],
			];
		}

		$toc      = [];
		$used_ids = [];
		$this->configure_external_links( $wrapper );
		$this->collect_headings_from_node( $wrapper, $toc, $used_ids );

		$rendered = '';
		// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMNode property.
		foreach ( iterator_to_array( $wrapper->childNodes ) as $child ) {
			$rendered .= $document->saveHTML( $child );
		}

		return [
			'html' => $rendered,
			'toc'  => $toc,
		];
	}

	/**
	 * Open absolute web links in a separate tab while leaving article navigation in place.
	 *
	 * @param DOMElement $wrapper Article fragment wrapper.
	 * @return void
	 */
	private function configure_external_links( DOMElement $wrapper ): void {
		foreach ( $wrapper->getElementsByTagName( 'a' ) as $link ) {
			$href = trim( $link->getAttribute( 'href' ) );
			if ( ! preg_match( '#^https?://#i', $href ) ) {
				continue;
			}

			$link->setAttribute( 'target', '_blank' );
			$link->setAttribute( 'rel', 'noopener noreferrer' );
		}
	}

	/**
	 * Traverse a DOM subtree in document order, adding H2-H4 headings to the TOC.
	 *
	 * @param DOMNode                                            $node Current node.
	 * @param array<int,array{id:string,title:string,level:int}> $toc TOC items.
	 * @param array<string,bool>                                 $used_ids Used ID map.
	 * @return void
	 */
	private function collect_headings_from_node( DOMNode $node, array &$toc, array &$used_ids ): void {
		// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMElement property.
		if ( $node instanceof DOMElement && preg_match( '/^h([2-4])$/', strtolower( $node->tagName ), $matches ) ) {
			// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMNode property.
			$text = trim( $node->textContent );
			if ( '' !== $text ) {
				$id = $node->getAttribute( 'id' );
				if ( '' === $id ) {
					$id = sanitize_title( $text );
				}
				$id = $this->make_unique_id( $id, $used_ids );
				$node->setAttribute( 'id', $id );

				$toc[] = [
					'id'    => $id,
					'title' => $text,
					'level' => (int) $matches[1],
				];
			}
		}

		// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMNode property.
		foreach ( iterator_to_array( $node->childNodes ) as $child ) {
			$this->collect_headings_from_node( $child, $toc, $used_ids );
		}
	}

	/**
	 * Create a unique heading ID.
	 *
	 * @param string             $id Base ID.
	 * @param array<string,bool> $used_ids Used ID map.
	 * @return string
	 */
	private function make_unique_id( string $id, array &$used_ids ): string {
		$id = '' === $id ? 'section' : $id;
		if ( empty( $used_ids[ $id ] ) ) {
			$used_ids[ $id ] = true;
			return $id;
		}

		$base = $id;
		$i    = 2;
		while ( ! empty( $used_ids[ $id ] ) ) {
			$id = $base . '-' . $i;
			++$i;
		}

		$used_ids[ $id ] = true;
		return $id;
	}

	/**
	 * Get the first level-one Markdown heading or a filename-derived fallback.
	 *
	 * @param string $markdown Markdown content.
	 * @param string $fallback Filename without extension.
	 * @return string
	 */
	private function get_article_title( string $markdown, string $fallback ): string {
		if ( preg_match( '/^\s*#\s+(.+)$/m', $markdown, $matches ) ) {
			return trim( wp_strip_all_tags( $matches[1] ) );
		}

		return $this->label_from_slug( $fallback );
	}

	/**
	 * Remove the first level-one heading because the shell renders article titles.
	 *
	 * @param string $markdown Markdown content.
	 * @return string
	 */
	private function remove_first_h1( string $markdown ): string {
		return (string) preg_replace( '/^\s*#\s+.+(?:\R|$)/m', '', $markdown, 1 );
	}

	/**
	 * Convert an ordered filename or directory slug into a display label.
	 *
	 * @param string $slug Filename or directory name.
	 * @return string
	 */
	private function label_from_slug( string $slug ): string {
		$label = (string) preg_replace( '/^\d+[-_.\s]+/', '', $slug );
		$label = str_replace( [ '-', '_' ], ' ', $label );

		return ucwords( trim( $label ) );
	}

	/**
	 * HTML allowed after Markdown conversion.
	 *
	 * @return array<string,array<string,bool|array<int,string>>>
	 */
	private function get_allowed_html(): array {
		return [
			'a'          => [
				'href'   => true,
				'title'  => true,
				'target' => true,
				'rel'    => true,
			],
			'blockquote' => [],
			'br'         => [],
			'code'       => [
				'class' => true,
			],
			'del'        => [],
			'div'        => [
				'class' => true,
			],
			'em'         => [],
			'h1'         => [
				'id' => true,
			],
			'h2'         => [
				'id' => true,
			],
			'h3'         => [
				'id' => true,
			],
			'h4'         => [
				'id' => true,
			],
			'h5'         => [
				'id' => true,
			],
			'h6'         => [
				'id' => true,
			],
			'hr'         => [],
			'input'      => [
				'checked'  => true,
				'disabled' => true,
				'type'     => true,
			],
			'li'         => [
				'class' => true,
			],
			'ol'         => [
				'class' => true,
				'start' => true,
			],
			'p'          => [],
			'pre'        => [],
			'span'       => [
				'class' => true,
			],
			'strong'     => [],
			'table'      => [],
			'tbody'      => [],
			'td'         => [
				'align' => true,
			],
			'th'         => [
				'align' => true,
			],
			'thead'      => [],
			'tr'         => [],
			'ul'         => [
				'class' => true,
			],
		];
	}
}
