<?php
/**
 * Dependency-free Markdown parser.
 *
 * @package MarkBlocks
 */

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

/**
 * A deliberately small, dependency-free Markdown parser for technical articles.
 * It supports the constructs converted by the Elementor editor importer and
 * escapes raw HTML by default.
 */
final class MarkBlocks_Markdown_Parser {
	private const MAX_MARKDOWN_BYTES          = 2097152;
	private const MAX_MARKDOWN_LINES          = 50000;
	private const MAX_QUOTE_DEPTH             = 8;
	private const MAX_QUOTE_RECURSION_BYTES   = 262144;
	private const MAX_LANGUAGE_LENGTH         = 64;

	private bool $show_copy;
	private string $theme;

	public function __construct( bool $show_copy = true, string $theme = 'midnight' ) {
		$this->show_copy = $show_copy;
		$this->theme     = in_array( $theme, [ 'midnight', 'light' ], true ) ? $theme : 'midnight';
	}

	public static function max_markdown_bytes(): int {
		return self::MAX_MARKDOWN_BYTES;
	}

	public static function max_markdown_lines(): int {
		return self::MAX_MARKDOWN_LINES;
	}

	public static function allowed_html(): array {
		return [
			'h1'         => [],
			'h2'         => [],
			'h3'         => [],
			'h4'         => [],
			'h5'         => [],
			'h6'         => [],
			'p'          => [ 'class' => true ],
			'strong'     => [],
			'em'         => [],
			'del'        => [],
			'a'          => [ 'href' => true, 'title' => true, 'rel' => true ],
			'img'        => [ 'src' => true, 'alt' => true, 'title' => true, 'loading' => true, 'decoding' => true ],
			'figure'     => [ 'class' => true ],
			'blockquote' => [],
			'ul'         => [],
			'ol'         => [],
			'li'         => [],
			'input'      => [ 'type' => true, 'disabled' => true, 'checked' => true ],
			'hr'         => [],
			'div'        => [ 'class' => true, 'data-language' => true ],
			'table'      => [],
			'thead'      => [],
			'tbody'      => [],
			'tr'         => [],
			'th'         => [ 'class' => true ],
			'td'         => [ 'class' => true ],
			'pre'        => [],
			'code'       => [ 'class' => true ],
			'span'       => [ 'class' => true ],
			'button'     => [ 'type' => true, 'class' => true, 'aria-label' => true ],
		];
	}

	public function parse( string $markdown ): string {
		if ( strlen( $markdown ) > self::MAX_MARKDOWN_BYTES ) {
			return '<p class="markblocks-markdown-error">' . esc_html__( 'Markdown content exceeds the 2 MB safety limit.', 'markblocks' ) . '</p>';
		}

		if ( self::exceeds_line_limit( $markdown ) ) {
			return '<p class="markblocks-markdown-error">' . esc_html__( 'Markdown content exceeds the 50,000-line safety limit.', 'markblocks' ) . '</p>';
		}

		return $this->parse_internal( $markdown, 0 );
	}

	/**
	 * Check the line limit without first normalizing or splitting the source.
	 * Counts LF, CRLF and CR line endings while avoiding a large line array.
	 */
	private static function exceeds_line_limit( string $markdown ): bool {
		$lines  = 1;
		$length = strlen( $markdown );

		for ( $index = 0; $index < $length; $index++ ) {
			$character = $markdown[ $index ];

			if ( "\r" === $character ) {
				if ( $index + 1 < $length && "\n" === $markdown[ $index + 1 ] ) {
					$index++;
				}
			} elseif ( "\n" !== $character ) {
				continue;
			}

			$lines++;
			if ( $lines > self::MAX_MARKDOWN_LINES ) {
				return true;
			}
		}

		return false;
	}

	private function parse_internal( string $markdown, int $depth ): string {
		$markdown = str_replace( [ "\r\n", "\r" ], "\n", $markdown );
		$lines    = explode( "\n", $markdown );
		$html     = [];
		$count    = count( $lines );

		for ( $i = 0; $i < $count; ) {
			$line = $lines[ $i ];

			if ( '' === trim( $line ) ) {
				$i++;
				continue;
			}

			if ( preg_match( '/^\s*(`{3,}|~{3,})\s*([^\s]*)[^\n]*$/', $line, $match ) ) {
				$fence    = $match[1];
				$language = $this->normalize_language( $match[2] ?? '' );
				$code     = [];
				$i++;

				while ( $i < $count && ! preg_match( '/^\s*' . preg_quote( $fence[0], '/' ) . '{' . strlen( $fence ) . ',}\s*$/', $lines[ $i ] ) ) {
					$code[] = $lines[ $i ];
					$i++;
				}

				if ( $i < $count ) {
					$i++;
				}

				$html[] = $this->render_code_block( implode( "\n", $code ), $language );
				continue;
			}

			if ( preg_match( '/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/', $line, $match ) ) {
				$level  = strlen( $match[1] );
				$html[] = sprintf( '<h%d>%s</h%d>', $level, $this->inline( $match[2] ), $level );
				$i++;
				continue;
			}

			if ( $i + 1 < $count && trim( $line ) !== '' && preg_match( '/^\s*(=+|-+)\s*$/', $lines[ $i + 1 ], $setext ) ) {
				$level  = '=' === $setext[1][0] ? 1 : 2;
				$html[] = sprintf( '<h%d>%s</h%d>', $level, $this->inline( trim( $line ) ), $level );
				$i     += 2;
				continue;
			}

			if ( preg_match( '/^\s{0,3}((\*\s*){3,}|(-\s*){3,}|(_\s*){3,})$/', $line ) ) {
				$html[] = '<hr>';
				$i++;
				continue;
			}

			if ( $this->is_table_header( $lines, $i ) ) {
				[ $table_html, $next ] = $this->parse_table( $lines, $i );
				$html[]                 = $table_html;
				$i                      = $next;
				continue;
			}

			if ( preg_match( '/^\s*>\s?(.*)$/', $line ) ) {
				$quote_lines = [];
				while ( $i < $count && preg_match( '/^\s*>\s?(.*)$/', $lines[ $i ], $quote_match ) ) {
					$quote_lines[] = $quote_match[1];
					$i++;
				}
				$quote_markdown = implode( "\n", $quote_lines );
				$quote_content  = $depth >= self::MAX_QUOTE_DEPTH || strlen( $quote_markdown ) > self::MAX_QUOTE_RECURSION_BYTES
					? '<p>' . $this->inline( implode( ' ', $quote_lines ) ) . '</p>'
					: $this->parse_internal( $quote_markdown, $depth + 1 );
				$html[] = '<blockquote>' . $quote_content . '</blockquote>';
				continue;
			}

			if ( preg_match( '/^\s*([-+*]|\d+[.)])\s+(.+)$/', $line ) ) {
				[ $list_html, $next ] = $this->parse_list( $lines, $i );
				$html[]                = $list_html;
				$i                     = $next;
				continue;
			}

			if ( preg_match( '/^\s*!\[([^\]]*)\]\(([^\s\)]+)(?:\s+["\']([^"\']*)["\'])?\)\s*$/', $line, $image ) ) {
				$url = $this->safe_url( $image[2], true );
				if ( '' !== $url ) {
					$title  = isset( $image[3] ) && '' !== $image[3] ? ' title="' . esc_attr( $image[3] ) . '"' : '';
					$html[] = '<figure class="markblocks-markdown-image"><img src="' . esc_url( $url, [ 'http', 'https' ] ) . '" alt="' . esc_attr( $image[1] ) . '" loading="lazy" decoding="async"' . $title . '></figure>';
					$i++;
					continue;
				}
			}

			$paragraph = [ trim( $line ) ];
			$i++;

			while ( $i < $count && '' !== trim( $lines[ $i ] ) && ! $this->starts_block( $lines, $i ) ) {
				$paragraph[] = trim( $lines[ $i ] );
				$i++;
			}

			$html[] = '<p>' . $this->inline( implode( "\n", $paragraph ) ) . '</p>';
		}

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

	private function starts_block( array $lines, int $index ): bool {
		$line = $lines[ $index ];

		return (bool) (
			preg_match( '/^\s*(`{3,}|~{3,})/', $line ) ||
			preg_match( '/^\s{0,3}#{1,6}\s+/', $line ) ||
			preg_match( '/^\s{0,3}((\*\s*){3,}|(-\s*){3,}|(_\s*){3,})$/', $line ) ||
			preg_match( '/^\s*>/', $line ) ||
			preg_match( '/^\s*([-+*]|\d+[.)])\s+/', $line ) ||
			preg_match( '/^\s*!\[[^\]]*\]\([^\)]+\)\s*$/', $line ) ||
			$this->is_table_header( $lines, $index )
		);
	}

	private function parse_list( array $lines, int $start ): array {
		$first_ordered = preg_match( '/^\s*\d+[.)]\s+/', $lines[ $start ] ) === 1;
		$tag           = $first_ordered ? 'ol' : 'ul';
		$items         = [];
		$count         = count( $lines );
		$i             = $start;

		while ( $i < $count && preg_match( '/^(\s*)([-+*]|\d+[.)])\s+(.+)$/', $lines[ $i ], $match ) ) {
			$is_ordered = ctype_digit( $match[2][0] );
			if ( $is_ordered !== $first_ordered ) {
				break;
			}

			$content = $match[3];
			$indent  = strlen( $match[1] );
			$i++;

			while ( $i < $count && '' !== trim( $lines[ $i ] ) && ! preg_match( '/^\s*([-+*]|\d+[.)])\s+/', $lines[ $i ] ) ) {
				$current_indent = strlen( $lines[ $i ] ) - strlen( ltrim( $lines[ $i ] ) );
				if ( $current_indent <= $indent && $this->starts_block( $lines, $i ) ) {
					break;
				}
				$content .= "\n" . trim( $lines[ $i ] );
				$i++;
			}

			$checked = null;
			if ( preg_match( '/^\[([ xX])\]\s+(.+)$/', $content, $task ) ) {
				$checked = strtolower( $task[1] ) === 'x';
				$content = $task[2];
			}

			$prefix  = null !== $checked ? '<input type="checkbox" disabled' . ( $checked ? ' checked' : '' ) . '> ' : '';
			$items[] = '<li>' . $prefix . $this->inline( $content ) . '</li>';
		}

		return [ '<' . $tag . '>' . implode( '', $items ) . '</' . $tag . '>', $i ];
	}

	private function is_table_header( array $lines, int $index ): bool {
		if ( $index + 1 >= count( $lines ) || strpos( $lines[ $index ], '|' ) === false ) {
			return false;
		}

		$separator = trim( $lines[ $index + 1 ] );
		return (bool) preg_match( '/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/', $separator );
	}

	private function parse_table( array $lines, int $start ): array {
		$headers    = $this->split_table_row( $lines[ $start ] );
		$alignments = $this->split_table_row( $lines[ $start + 1 ] );
		$head_html  = [];

		foreach ( $headers as $index => $header ) {
			$alignment = $this->table_alignment( $alignments[ $index ] ?? '' );
			$attr        = '' !== $alignment ? ' class="markblocks-align-' . $alignment . '"' : '';
			$head_html[] = '<th' . $attr . '>' . $this->inline( trim( $header ) ) . '</th>';
		}

		$i     = $start + 2;
		$rows  = [];
		$count = count( $lines );

		while ( $i < $count && '' !== trim( $lines[ $i ] ) && strpos( $lines[ $i ], '|' ) !== false ) {
			$cells     = $this->split_table_row( $lines[ $i ] );
			$cell_html = [];
			foreach ( $headers as $index => $_header ) {
				$alignment  = $this->table_alignment( $alignments[ $index ] ?? '' );
				$attr        = '' !== $alignment ? ' class="markblocks-align-' . $alignment . '"' : '';
				$cell_html[] = '<td' . $attr . '>' . $this->inline( trim( $cells[ $index ] ?? '' ) ) . '</td>';
			}
			$rows[] = '<tr>' . implode( '', $cell_html ) . '</tr>';
			$i++;
		}

		$html = '<div class="markblocks-table-wrap"><table><thead><tr>' . implode( '', $head_html ) . '</tr></thead><tbody>' . implode( '', $rows ) . '</tbody></table></div>';
		return [ $html, $i ];
	}

	private function split_table_row( string $row ): array {
		$row = trim( $row );
		$row = preg_replace( '/^\||\|$/', '', $row );
		$cells   = [];
		$current = '';
		$escaped = false;
		$length  = strlen( (string) $row );

		for ( $i = 0; $i < $length; $i++ ) {
			$character = $row[ $i ];
			if ( '|' === $character && ! $escaped ) {
				$cells[] = $current;
				$current = '';
				continue;
			}

			$current .= $character;
			if ( '\\' === $character ) {
				$escaped = ! $escaped;
			} else {
				$escaped = false;
			}
		}

		$cells[] = $current;
		return array_map( static function ( string $cell ): string {
			return str_replace( '\\|', '|', $cell );
		}, $cells );
	}

	private function table_alignment( string $separator ): string {
		$separator = trim( $separator );
		$left      = isset( $separator[0] ) && ':' === $separator[0];
		$right     = '' !== $separator && ':' === substr( $separator, -1 );

		if ( $left && $right ) {
			return 'center';
		}
		if ( $right ) {
			return 'right';
		}
		if ( $left ) {
			return 'left';
		}
		return 'left';
	}

	private function inline( string $text ): string {
		$text         = str_replace( "\n", ' ', $text );
		$placeholders = [];
		$store        = static function ( string $html ) use ( &$placeholders ): string {
			$key                  = "\x1A" . count( $placeholders ) . "\x1A";
			$placeholders[ $key ] = $html;
			return $key;
		};

		$text = preg_replace_callback(
			'/`([^`]+)`/',
			function ( array $match ) use ( $store ): string {
				return $store( '<code class="markblocks-inline-code">' . esc_html( $match[1] ) . '</code>' );
			},
			$text
		);

		$text = preg_replace_callback(
			'/!\[([^\]]*)\]\(([^\s\)]+)(?:\s+["\']([^"\']*)["\'])?\)/',
			function ( array $match ) use ( $store ): string {
				$url = $this->safe_url( $match[2], true );
				if ( '' === $url ) {
					return esc_html( $match[0] );
				}
				$title = isset( $match[3] ) && '' !== $match[3] ? ' title="' . esc_attr( $match[3] ) . '"' : '';
				return $store( '<img src="' . esc_url( $url, [ 'http', 'https' ] ) . '" alt="' . esc_attr( $match[1] ) . '" loading="lazy" decoding="async"' . $title . '>' );
			},
			$text
		);

		$text = preg_replace_callback(
			'/\[([^\]]+)\]\(([^\s\)]+)(?:\s+["\']([^"\']*)["\'])?\)/',
			function ( array $match ) use ( $store ): string {
				$url = $this->safe_url( $match[2] );
				if ( '' === $url ) {
					return esc_html( $match[0] );
				}
				$title = isset( $match[3] ) && '' !== $match[3] ? ' title="' . esc_attr( $match[3] ) . '"' : '';
				return $store( '<a href="' . esc_url( $url, [ 'http', 'https', 'mailto', 'tel' ] ) . '"' . $title . '>' . esc_html( $match[1] ) . '</a>' );
			},
			$text
		);

		$text = esc_html( $text );
		$text = preg_replace( '/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $text );
		$text = preg_replace( '/__(.+?)__/s', '<strong>$1</strong>', $text );
		$text = preg_replace( '/~~(.+?)~~/s', '<del>$1</del>', $text );
		$text = preg_replace( '/(?<!\*)\*([^*]+)\*(?!\*)/s', '<em>$1</em>', $text );
		$text = preg_replace( '/(?<!_)_([^_]+)_(?!_)/s', '<em>$1</em>', $text );

		return strtr( (string) $text, $placeholders );
	}

	private function render_code_block( string $code, string $language ): string {
		$language_label = '' !== $language ? strtoupper( $language ) : __( 'Code', 'markblocks' );
		$copy_button    = $this->show_copy
			? '<button type="button" class="markblocks-copy-code" aria-label="' . esc_attr( __( 'Copy code', 'markblocks' ) ) . '">' . esc_html( __( 'Copy', 'markblocks' ) ) . '</button>'
			: '';

		return '<div class="markblocks-code-block markblocks-theme-' . esc_attr( $this->theme ) . '" data-language="' . esc_attr( $language ) . '">' .
			'<div class="markblocks-code-toolbar"><span class="markblocks-code-language">' . esc_html( $language_label ) . '</span>' . $copy_button . '</div>' .
			'<pre><code class="language-' . esc_attr( $language ) . '">' . esc_html( $code ) . '</code></pre>' .
			'</div>';
	}

	private function normalize_language( string $language ): string {
		$language = strtolower( preg_replace( '/[^a-zA-Z0-9_+\-.#]/', '', $language ) ?? '' );
		$language = substr( $language, 0, self::MAX_LANGUAGE_LENGTH );
		$aliases  = [
			'sh'           => 'bash',
			'shell'        => 'bash',
			'console'      => 'bash',
			'cli'          => 'fortios',
			'fortigate'    => 'fortios',
			'fortimanager' => 'fortios',
			'fmg'          => 'fortios',
			'fortianalyzer' => 'fortios',
			'faz'          => 'fortios',
			'js'           => 'javascript',
			'ts'           => 'typescript',
			'html'         => 'markup',
			'xml'          => 'markup',
			'ps1'          => 'powershell',
			'py'           => 'python',
			'yml'          => 'yaml',
		];

		return $aliases[ $language ] ?? $language;
	}

	private function safe_url( string $url, bool $for_image = false ): string {
		$url = trim( html_entity_decode( $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
		if ( '' === $url || preg_match( '/[\x00-\x20\x7F<>"\']|%(?:0d|0a)/i', $url ) ) {
			return '';
		}

		$is_http     = (bool) preg_match( '#^https?://#i', $url );
		$is_contact  = ! $for_image && (bool) preg_match( '#^(mailto:|tel:)#i', $url );
		$is_fragment = 0 === strpos( $url, '#' );
		$is_query    = 0 === strpos( $url, '?' );
		$is_relative = ( 0 === strpos( $url, './' ) || 0 === strpos( $url, '../' ) );
		$is_root     = 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' );

		if ( false !== strpos( $url, '\\' ) ) {
			return '';
		}

		if ( ! $is_http && ! $is_contact && ! $is_fragment && ! $is_query && ! $is_relative && ! $is_root ) {
			return '';
		}

		if ( function_exists( 'esc_url_raw' ) ) {
			$protocols = $for_image ? [ 'http', 'https' ] : [ 'http', 'https', 'mailto', 'tel' ];
			$clean     = esc_url_raw( $url, $protocols );
			return is_string( $clean ) ? $clean : '';
		}

		return $url;
	}

}
