<?php

namespace TableOfContents;

use WP_Post;
use WP_PowerSuite\Core\AbstractModule;

class Module extends AbstractModule {

	private const MODULE_ID            = 'table-of-contents';
	private const MODULE_VERSION       = '1.1.1';
	private const SHORTCODE            = 'wpps_toc';
	private const LEGACY_SHORTCODE     = 'toc';
	private const DEFAULT_TOC_TITLE    = 'Table of Contents';
	private const PREPARE_PRIORITY     = 10;
	private const AUTO_INSERT_PRIORITY = 12;

	/** @var array<string, mixed>|null */
	private ?array $settings_cache = null;

	/** @var array<int, list<array{level: int, text: string, id: string}>> */
	private static array $prepared_items_by_post = [];

	private static ?int $auto_inserted_for_post = null;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Table of Contents', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Clickable table of contents from headings—ideal for long articles and docs.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return 'frontend';
	}

	public function getDependencies(): array {
		return [];
	}

	protected function registerHooks(): void {
		if ( is_admin() ) {
			return;
		}

		add_shortcode( self::SHORTCODE, [ $this, 'renderShortcode' ] );
		if ( ! shortcode_exists( self::LEGACY_SHORTCODE ) ) {
			add_shortcode( self::LEGACY_SHORTCODE, [ $this, 'renderShortcode' ] );
		}

		add_filter( 'the_content', [ $this, 'filterPrepareHeadings' ], self::PREPARE_PRIORITY );
		add_filter( 'the_content', [ $this, 'filterAutoInsertToc' ], self::AUTO_INSERT_PRIORITY );
		add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
	}

	public static function getDefaultSettings(): array {
		return [
			'auto_insert'        => true,
			'enabled_post_types' => [ 'post' ],
			'position'           => 'after_first_paragraph',
			'minimum_headings'   => 3,
			'heading_levels'     => '2,3',
			'toc_title'          => self::DEFAULT_TOC_TITLE,
			'numbered_list'      => false,
		];
	}

	/**
	 * @param array<string, mixed> $raw
	 * @return array<string, mixed>
	 */
	public static function normalizeSettings( array $raw ): array {
		$settings = wp_parse_args( $raw, self::getDefaultSettings() );

		$settings['auto_insert']   = ! empty( $settings['auto_insert'] );
		$settings['numbered_list'] = ! empty( $settings['numbered_list'] );
		$settings['minimum_headings'] = max( 1, min( 50, (int) ( $settings['minimum_headings'] ?? 3 ) ) );

		$pos         = isset( $settings['position'] ) ? (string) $settings['position'] : 'after_first_paragraph';
		$allowed_pos = [ 'after_first_paragraph', 'before_first_heading', 'after_first_heading', 'top' ];
		$settings['position'] = in_array( $pos, $allowed_pos, true ) ? $pos : 'after_first_paragraph';

		$levels = isset( $settings['heading_levels'] ) ? (string) $settings['heading_levels'] : '2,3';
		$levels = preg_replace( '/[^0-9,]/', '', $levels ) ?? '';
		$settings['heading_levels'] = $levels !== '' ? $levels : '2,3';

		$title = isset( $settings['toc_title'] ) ? sanitize_text_field( (string) $settings['toc_title'] ) : '';
		$title = function_exists( 'mb_substr' ) ? mb_substr( $title, 0, 200 ) : substr( $title, 0, 200 );
		$settings['toc_title'] = $title !== '' ? $title : self::DEFAULT_TOC_TITLE;

		$settings['enabled_post_types'] = self::sanitizePostTypes( $settings['enabled_post_types'] ?? [] );

		return $settings;
	}

	/**
	 * @param mixed $types
	 * @return list<string>
	 */
	private static function sanitizePostTypes( $types ): array {
		if ( ! is_array( $types ) ) {
			return [ 'post' ];
		}

		$out = [];
		foreach ( $types as $type ) {
			$key = sanitize_key( (string) $type );
			if ( $key !== '' && post_type_exists( $key ) && ! in_array( $key, $out, true ) ) {
				$out[] = $key;
			}
		}

		return $out === [] ? [ 'post' ] : $out;
	}

	public function enqueueAssets(): void {
		if ( ! is_singular() ) {
			return;
		}

		$post = get_post();
		if ( ! $post instanceof WP_Post || ! $this->isPostTypeEnabled( $post->post_type ) ) {
			return;
		}

		$settings = $this->getSettings();
		if ( empty( $settings['auto_insert'] ) && ! $this->postHasTocShortcode( $post ) ) {
			return;
		}

		$base = WP_POWERSUITE_PLUGIN_URL . 'modules/' . self::MODULE_ID . '/assets/';
		$dir  = WP_POWERSUITE_PLUGIN_DIR . 'modules' . DIRECTORY_SEPARATOR . self::MODULE_ID . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR;

		$css = $dir . 'table-of-contents.css';
		if ( is_readable( $css ) ) {
			wp_enqueue_style( 'wpps-toc', $base . 'table-of-contents.css', [], (string) filemtime( $css ) );
		}

		$js = $dir . 'table-of-contents.js';
		if ( is_readable( $js ) ) {
			wp_enqueue_script( 'wpps-toc', $base . 'table-of-contents.js', [], (string) filemtime( $js ), true );
		}
	}

	/**
	 * @param array<string, mixed> $atts
	 */
	public function renderShortcode( $atts = [], ?string $content = null, string $tag = '' ): string {
		unset( $content, $tag );

		if ( ! is_array( $atts ) ) {
			$atts = [];
		}

		$settings = $this->getSettings();
		$post     = null;
		$post_id  = 0;

		if ( is_singular() ) {
			$p = get_post();
			if ( $p instanceof WP_Post ) {
				$post    = $p;
				$post_id = (int) $p->ID;
			}
		}

		if ( $post instanceof WP_Post && ! $this->isPostTypeEnabled( $post->post_type ) ) {
			return '';
		}

		$items = [];
		if ( $post_id > 0 && isset( self::$prepared_items_by_post[ $post_id ] ) ) {
			$items = self::$prepared_items_by_post[ $post_id ];
		}

		if ( $items === [] ) {
			$html = isset( $atts['content'] ) ? (string) $atts['content'] : '';
			if ( $html === '' && $post instanceof WP_Post ) {
				$html = (string) ( $post->post_content ?? '' );
			}
			if ( $html === '' ) {
				return '';
			}
			$levels = $this->parseHeadingLevels( (string) ( $settings['heading_levels'] ?? '2,3' ) );
			$items  = $this->collectHeadingItems( $html, $levels );
		}

		if ( count( $items ) < $this->minimumHeadings( $settings ) ) {
			return '';
		}

		return $this->buildTocHtml( $items, $settings );
	}

	public function filterPrepareHeadings( string $content ): string {
		if ( ! is_singular() || trim( $content ) === '' ) {
			return $content;
		}

		if ( in_the_loop() && ! is_main_query() ) {
			return $content;
		}

		$post = get_post();
		if ( ! $post instanceof WP_Post || ! $this->shouldProcessContent( $post ) ) {
			return $content;
		}

		$settings = $this->getSettings();
		$levels   = $this->parseHeadingLevels( (string) ( $settings['heading_levels'] ?? '2,3' ) );

		[ $content, $items ] = $this->applyHeadingAnchorsAndCollect( $content, $levels );
		self::$prepared_items_by_post[ (int) $post->ID ] = $items;

		return $content;
	}

	public function filterAutoInsertToc( string $content ): string {
		if ( ! is_singular() || trim( $content ) === '' ) {
			return $content;
		}

		if ( in_the_loop() && ! is_main_query() ) {
			return $content;
		}

		$post = get_post();
		if ( ! $post instanceof WP_Post || ! $this->isPostTypeEnabled( $post->post_type ) ) {
			return $content;
		}

		if ( self::$auto_inserted_for_post === (int) $post->ID ) {
			return $content;
		}

		$settings = $this->getSettings();
		if ( empty( $settings['auto_insert'] ) || $this->postHasTocShortcode( $post ) ) {
			return $content;
		}

		$items = self::$prepared_items_by_post[ (int) $post->ID ] ?? [];
		if ( count( $items ) < $this->minimumHeadings( $settings ) ) {
			return $content;
		}

		self::$auto_inserted_for_post = (int) $post->ID;

		return $this->insertTocIntoContent(
			$content,
			$this->buildTocHtml( $items, $settings ),
			(string) ( $settings['position'] ?? 'after_first_paragraph' )
		);
	}

	private function shouldProcessContent( WP_Post $post ): bool {
		if ( ! $this->isPostTypeEnabled( $post->post_type ) ) {
			return false;
		}

		$settings = $this->getSettings();

		return ! empty( $settings['auto_insert'] ) || $this->postHasTocShortcode( $post );
	}

	private function postHasTocShortcode( WP_Post $post ): bool {
		$raw = (string) ( $post->post_content ?? '' );
		if ( $raw === '' ) {
			return false;
		}

		return has_shortcode( $raw, self::SHORTCODE )
			|| has_shortcode( $raw, self::LEGACY_SHORTCODE )
			|| (bool) preg_match( '/\[\s*wpps_toc\b/', $raw )
			|| (bool) preg_match( '/\[\s*toc(?:\s|\])/i', $raw );
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function minimumHeadings( array $settings ): int {
		return max( 1, min( 50, (int) ( $settings['minimum_headings'] ?? 3 ) ) );
	}

	/** @return list<string> */
	private function getTrackedPostTypes(): array {
		return self::sanitizePostTypes( $this->getSettings()['enabled_post_types'] ?? [ 'post' ] );
	}

	private function isPostTypeEnabled( string $post_type ): bool {
		return in_array( $post_type, $this->getTrackedPostTypes(), true );
	}

	/**
	 * @param list<int> $levels
	 */
	private function headingPattern( array $levels ): string {
		$tags = [];
		foreach ( $levels as $level ) {
			$tags[] = 'h' . (int) $level;
		}

		return '#<(' . implode( '|', $tags ) . ')(\s[^>]*)?>(.*?)</\1>#is';
	}

	private function parseExistingHeadingId( string $attrs ): string {
		if ( ! preg_match( '/\bid\s*=\s*(["\'])([^"\']*)\1/i', $attrs, $m ) ) {
			return '';
		}

		$id = trim( $m[2] );
		if ( $id === '' || strlen( $id ) > 200 ) {
			return '';
		}

		if ( ! preg_match( '/^[a-zA-Z][\w:.-]*$/', $id ) ) {
			return '';
		}

		return $id;
	}

	private function injectIdIntoOpenBody( string $open_body, string $id ): string {
		if ( preg_match( '/\bid\s*=\s*(["\'])[^"\']*\1/i', $open_body ) ) {
			return $open_body;
		}

		return $open_body . ' id="' . esc_attr( $id ) . '"';
	}

	/**
	 * @param array<string, int> $used
	 */
	private function uniqueAnchorId( string $text, array &$used ): string {
		$id = sanitize_title( $text );
		if ( $id === '' ) {
			$id = 'heading';
		}

		if ( isset( $used[ $id ] ) ) {
			$used[ $id ]++;
			$id = $id . '-' . $used[ $id ];
		} else {
			$used[ $id ] = 1;
		}

		return $id;
	}

	/**
	 * @param array<int|string, string> $m
	 * @param array<string, int>        $used
	 * @return array{level: int, text: string, id: string}|null
	 */
	private function headingEntryFromMatch( array $m, array &$used ): ?array {
		$tag   = strtolower( $m[1] );
		$level = (int) substr( $tag, 1 );
		$attrs = $m[2] ?? '';
		$inner = $m[3] ?? '';
		$text  = trim( wp_strip_all_tags( $inner ) );
		if ( $text === '' ) {
			return null;
		}

		$existing = $this->parseExistingHeadingId( $attrs );
		if ( $existing !== '' ) {
			return [
				'level' => $level,
				'text'  => $text,
				'id'    => $existing,
			];
		}

		return [
			'level' => $level,
			'text'  => $text,
			'id'    => $this->uniqueAnchorId( $text, $used ),
		];
	}

	/**
	 * @param list<int> $levels
	 * @return list<array{level: int, text: string, id: string}>
	 */
	private function collectHeadingItems( string $content, array $levels ): array {
		$pattern = $this->headingPattern( $levels );
		$items   = [];
		$used    = [];
		if ( ! preg_match_all( $pattern, $content, $matches, PREG_SET_ORDER ) ) {
			return [];
		}

		foreach ( $matches as $m ) {
			$entry = $this->headingEntryFromMatch( $m, $used );
			if ( $entry !== null ) {
				$items[] = $entry;
			}
		}

		return $items;
	}

	/**
	 * @param list<int> $levels
	 * @return array{0: string, 1: list<array{level: int, text: string, id: string}>}
	 */
	private function applyHeadingAnchorsAndCollect( string $content, array $levels ): array {
		$pattern = $this->headingPattern( $levels );
		$items   = [];
		$used    = [];

		$out = preg_replace_callback(
			$pattern,
			function ( array $m ) use ( &$items, &$used ): string {
				$entry = $this->headingEntryFromMatch( $m, $used );
				if ( $entry === null ) {
					return $m[0];
				}

				$items[] = $entry;
				$attrs   = $m[2] ?? '';
				if ( $this->parseExistingHeadingId( $attrs ) !== '' ) {
					return $m[0];
				}

				$open_body = $m[1] . $attrs;
				$new_open  = $this->injectIdIntoOpenBody( $open_body, $entry['id'] );

				return '<' . $new_open . '>' . ( $m[3] ?? '' ) . '</' . $m[1] . '>';
			},
			$content
		);

		if ( ! is_string( $out ) ) {
			return [ $content, [] ];
		}

		return [ $out, $items ];
	}

	/** @return list<int> */
	private function parseHeadingLevels( string $text ): array {
		$levels = [];
		foreach ( array_map( 'trim', explode( ',', $text ) ) as $part ) {
			if ( $part === '' ) {
				continue;
			}
			$n = (int) $part;
			if ( $n >= 1 && $n <= 6 ) {
				$levels[ $n ] = true;
			}
		}

		$levels = array_keys( $levels );
		sort( $levels, SORT_NUMERIC );

		return $levels !== [] ? $levels : [ 2, 3 ];
	}

	/**
	 * @param list<array{level: int, text: string, id: string}> $items
	 * @param array<string, mixed>                            $settings
	 */
	private function buildTocHtml( array $items, array $settings ): string {
		if ( $items === [] ) {
			return '';
		}

		$stored_title = isset( $settings['toc_title'] ) ? trim( (string) $settings['toc_title'] ) : '';
		$title        = $stored_title === '' || $stored_title === self::DEFAULT_TOC_TITLE
			? __( self::DEFAULT_TOC_TITLE, 'wp-powersuite' )
			: $stored_title;
		$numbered = ! empty( $settings['numbered_list'] );
		$list_tag = $numbered ? 'ol' : 'ul';
		$list_cls = 'wpps-toc__list' . ( $numbered ? ' wpps-toc__list--numbered' : '' );

		$out = '<nav class="wpps-toc" aria-label="' . esc_attr( $title ) . '">';
		if ( $title !== '' ) {
			$out .= '<p class="wpps-toc__title">' . esc_html( $title ) . '</p>';
		}
		$out .= '<' . $list_tag . ' class="' . esc_attr( $list_cls ) . '">';

		$min_level = min( array_column( $items, 'level' ) );
		foreach ( $items as $item ) {
			$depth = $item['level'] - $min_level;
			$out  .= '<li class="wpps-toc__item wpps-toc__item--level-' . (int) $item['level'] . '" style="margin-left:' . ( $depth * 1.25 ) . 'em;">';
			$out  .= '<a class="wpps-toc__link" href="#' . esc_attr( $item['id'] ) . '">' . esc_html( $item['text'] ) . '</a>';
			$out  .= '</li>';
		}
		$out .= '</' . $list_tag . '></nav>';

		return $out;
	}

	private function insertTocIntoContent( string $content, string $toc_html, string $position ): string {
		$block = "\n\n" . $toc_html . "\n\n";

		$after_first_p = static function ( string $html ): ?int {
			if ( preg_match( '#</p>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
				return $m[0][1] + strlen( $m[0][0] );
			}

			return null;
		};
		$before_first_heading = static function ( string $html ): ?int {
			if ( preg_match( '#<h[1-6][^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
				return $m[0][1];
			}

			return null;
		};
		$after_first_heading = static function ( string $html ): ?int {
			if ( preg_match( '#</h[1-6]>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
				return $m[0][1] + strlen( $m[0][0] );
			}

			return null;
		};

		switch ( $position ) {
			case 'top':
				return $block . $content;
			case 'before_first_heading':
				$pos = $before_first_heading( $content );

				return $pos !== null ? substr( $content, 0, $pos ) . $block . substr( $content, $pos ) : $block . $content;
			case 'after_first_heading':
				$pos = $after_first_heading( $content );

				return $pos !== null ? substr( $content, 0, $pos ) . $block . substr( $content, $pos ) : $block . $content;
			case 'after_first_paragraph':
			default:
				$pos = $after_first_p( $content );
				if ( $pos !== null ) {
					return substr( $content, 0, $pos ) . $block . substr( $content, $pos );
				}
				$pos = $before_first_heading( $content );

				return $pos !== null ? substr( $content, 0, $pos ) . $block . substr( $content, $pos ) : $block . $content;
		}
	}

	/** @return array<string, mixed> */
	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}

		$raw                  = $this->moduleSettings()->all( [] );
		$this->settings_cache = self::normalizeSettings( is_array( $raw ) ? $raw : [] );

		return $this->settings_cache;
	}
}
