<?php
/**
 * Wally Monitor — DOMDocument-based HTML Accessibility Checker
 *
 * Replaces regex-based HTML parsing with a proper DOM parser for
 * server-side accessibility checks (CLI, Cron, REST API).
 *
 * @package    WallyMonitor
 * @subpackage Includes
 * @since      1.4.0
 */

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

class Wally_HTML_Checker {

	/** @var DOMDocument */
	private $doc;

	/** @var DOMXPath */
	private $xpath;

	/** @var string Raw HTML for fallback checks */
	private $html;

	/** @var array Penalty weights per impact level */
	private static $weights = array(
		'critical' => 8,
		'serious'  => 4,
		'moderate' => 2,
		'minor'    => 1,
	);

	/** @var array Max penalty caps per impact level */
	private static $caps = array(
		'critical' => 40,
		'serious'  => 20,
		'moderate' => 10,
		'minor'    => 5,
	);

	/**
	 * Construct checker from raw HTML string.
	 *
	 * @param string $html Full HTML of the page.
	 */
	public function __construct( $html ) {
		$this->html = $html;
		$this->doc  = new DOMDocument();

		libxml_use_internal_errors( true );
		$this->doc->loadHTML(
			'<?xml encoding="UTF-8">' . $html,
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR
		);
		libxml_clear_errors();

		$this->xpath = new DOMXPath( $this->doc );
	}

	// ─── Core Checks (shared by Cron + REST API) ───────────────────────

	/**
	 * Run the 12 core accessibility checks using DOMDocument.
	 *
	 * @return array { violations: array[], penalty: int }
	 */
	public function run_core_checks() {
		$checks = array(
			array(
				'id'     => 'image-alt',
				'impact' => 'critical',
				'desc'   => __( 'Imagens sem alt', 'wally-monitor' ),
				'query'  => '//img[not(@alt)]',
			),
			array(
				'id'     => 'empty-link',
				'impact' => 'serious',
				'desc'   => __( 'Links vazios', 'wally-monitor' ),
				'method' => 'check_empty_links',
			),
			array(
				'id'     => 'empty-heading',
				'impact' => 'serious',
				'desc'   => __( 'Títulos vazios', 'wally-monitor' ),
				'method' => 'check_empty_headings',
			),
			array(
				'id'     => 'html-lang',
				'impact' => 'critical',
				'desc'   => __( 'Sem atributo lang', 'wally-monitor' ),
				'query'  => '//html[not(@lang)]',
			),
			array(
				'id'     => 'iframe-no-title',
				'impact' => 'serious',
				'desc'   => __( 'Iframes sem título', 'wally-monitor' ),
				'query'  => '//iframe[not(@title)]',
			),
			array(
				'id'     => 'empty-button',
				'impact' => 'critical',
				'desc'   => __( 'Botões vazios', 'wally-monitor' ),
				'method' => 'check_empty_buttons',
			),
			array(
				'id'     => 'meta-refresh',
				'impact' => 'critical',
				'desc'   => __( 'Meta refresh', 'wally-monitor' ),
				'query'  => '//meta[translate(@http-equiv,"REFSH","refsh")="refresh"]',
			),
			array(
				'id'     => 'autoplay',
				'impact' => 'serious',
				'desc'   => __( 'Autoplay sem muted', 'wally-monitor' ),
				'query'  => '//audio[@autoplay and not(@muted)] | //video[@autoplay and not(@muted)]',
			),
			array(
				'id'     => 'positive-tabindex',
				'impact' => 'serious',
				'desc'   => __( 'Tabindex positivo', 'wally-monitor' ),
				'method' => 'check_positive_tabindex',
			),
			array(
				'id'     => 'marquee',
				'impact' => 'serious',
				'desc'   => __( 'Elemento <marquee>', 'wally-monitor' ),
				'query'  => '//marquee',
			),
			array(
				'id'     => 'generic-link',
				'impact' => 'moderate',
				'desc'   => __( 'Link genérico', 'wally-monitor' ),
				'method' => 'check_generic_links',
			),
			array(
				'id'     => 'outline-none',
				'impact' => 'moderate',
				'desc'   => __( 'Outline removido inline', 'wally-monitor' ),
				'method' => 'check_outline_none',
			),
		);

		$violations = array();
		$penalty    = 0;

		foreach ( $checks as $check ) {
			$count = 0;

			if ( ! empty( $check['query'] ) ) {
				$nodes = $this->xpath->query( $check['query'] );
				$count = $nodes ? $nodes->length : 0;
			} elseif ( ! empty( $check['method'] ) && method_exists( $this, $check['method'] ) ) {
				$count = $this->{$check['method']}();
			}

			if ( $count > 0 ) {
				$violations[] = array(
					'id'          => $check['id'],
					'impact'      => $check['impact'],
					'description' => $check['desc'],
					'count'       => $count,
				);
				$penalty += self::calculate_penalty( $check['impact'], $count );
			}
		}

		return array(
			'violations' => $violations,
			'penalty'    => $penalty,
		);
	}

	// ─── Complex Core Check Methods ─────────────────────────────────────

	/**
	 * Count empty links (no text content after trimming whitespace).
	 *
	 * @return int
	 */
	private function check_empty_links() {
		$links = $this->xpath->query( '//a' );
		$count = 0;
		foreach ( $links as $link ) {
			$text = trim( $link->textContent );
			if ( '' === $text && ! $link->hasAttribute( 'aria-label' ) && ! $link->hasAttribute( 'aria-labelledby' ) ) {
				// Also check for child img with alt
				$imgs = $link->getElementsByTagName( 'img' );
				$has_alt = false;
				foreach ( $imgs as $img ) {
					if ( $img->hasAttribute( 'alt' ) && '' !== trim( $img->getAttribute( 'alt' ) ) ) {
						$has_alt = true;
						break;
					}
				}
				if ( ! $has_alt ) {
					$count++;
				}
			}
		}
		return $count;
	}

	/**
	 * Count empty headings (h1-h6).
	 *
	 * @return int
	 */
	private function check_empty_headings() {
		$count = 0;
		for ( $i = 1; $i <= 6; $i++ ) {
			$headings = $this->doc->getElementsByTagName( 'h' . $i );
			foreach ( $headings as $h ) {
				if ( '' === trim( $h->textContent ) ) {
					$count++;
				}
			}
		}
		return $count;
	}

	/**
	 * Count empty buttons (no text, no aria-label, no child img with alt).
	 *
	 * @return int
	 */
	private function check_empty_buttons() {
		$buttons = $this->doc->getElementsByTagName( 'button' );
		$count   = 0;
		foreach ( $buttons as $btn ) {
			$text = trim( $btn->textContent );
			if ( '' === $text && ! $btn->hasAttribute( 'aria-label' ) && ! $btn->hasAttribute( 'aria-labelledby' ) ) {
				$count++;
			}
		}
		return $count;
	}

	/**
	 * Count elements with positive tabindex.
	 *
	 * @return int
	 */
	private function check_positive_tabindex() {
		$nodes = $this->xpath->query( '//*[@tabindex]' );
		$count = 0;
		foreach ( $nodes as $node ) {
			$val = (int) $node->getAttribute( 'tabindex' );
			if ( $val > 0 ) {
				$count++;
			}
		}
		return $count;
	}

	/**
	 * Count links with generic text (click here, read more, etc.).
	 *
	 * @return int
	 */
	private function check_generic_links() {
		$generic = array( 'clique aqui', 'click here', 'saiba mais', 'read more', 'leia mais', 'more', 'here', 'aqui', 'link' );
		$links   = $this->doc->getElementsByTagName( 'a' );
		$count   = 0;
		foreach ( $links as $link ) {
			$text = strtolower( trim( $link->textContent ) );
			if ( in_array( $text, $generic, true ) ) {
				$count++;
			}
		}
		return $count;
	}

	/**
	 * Count elements with inline style outline:none or outline:0.
	 *
	 * @return int
	 */
	private function check_outline_none() {
		$nodes = $this->xpath->query( '//*[@style]' );
		$count = 0;
		foreach ( $nodes as $node ) {
			$style = $node->getAttribute( 'style' );
			if ( preg_match( '/outline\s*:\s*(?:none|0)\b/i', $style ) ) {
				$count++;
			}
		}
		return $count;
	}

	// ─── CLI Validators (DOMDocument replacements) ──────────────────────

	/**
	 * Check for missing <title> element.
	 *
	 * @return int 1 if missing, 0 otherwise.
	 */
	public function validate_page_title() {
		$titles = $this->doc->getElementsByTagName( 'title' );
		return ( $titles->length === 0 ) ? 1 : 0;
	}

	/**
	 * Count data tables without <th> headers.
	 *
	 * @return int
	 */
	public function validate_table_headers() {
		$tables = $this->doc->getElementsByTagName( 'table' );
		$count  = 0;
		foreach ( $tables as $table ) {
			$ths = $table->getElementsByTagName( 'th' );
			if ( $ths->length === 0 ) {
				$count++;
			}
		}
		return $count;
	}

	/**
	 * Check for skip navigation link.
	 *
	 * @return int 1 if missing, 0 if present.
	 */
	public function validate_skip_nav() {
		// Check for links with href pointing to #main, #content, #conteudo
		$links = $this->doc->getElementsByTagName( 'a' );
		foreach ( $links as $link ) {
			$href  = $link->getAttribute( 'href' );
			$class = $link->getAttribute( 'class' );

			// Href targets main content
			if ( preg_match( '/^#(main|content|conteudo)/i', $href ) ) {
				return 0;
			}
			// Class contains "skip"
			if ( false !== stripos( $class, 'skip' ) ) {
				return 0;
			}
		}
		return 1;
	}

	/**
	 * Count heading hierarchy skips (e.g. h1 → h4 without h2/h3).
	 *
	 * @return int
	 */
	public function validate_heading_hierarchy() {
		$levels = array();
		for ( $i = 1; $i <= 6; $i++ ) {
			$headings = $this->doc->getElementsByTagName( 'h' . $i );
			foreach ( $headings as $h ) {
				// Use the element's position in the document via XPath
				$levels[] = $i;
			}
		}

		// DOMDocument groups by tag, so we need order. Re-query with XPath.
		$nodes  = $this->xpath->query( '//h1|//h2|//h3|//h4|//h5|//h6' );
		$levels = array();
		foreach ( $nodes as $node ) {
			$levels[] = (int) substr( $node->tagName, 1 );
		}

		if ( count( $levels ) < 2 ) {
			return 0;
		}

		$skips = 0;
		for ( $i = 1, $len = count( $levels ); $i < $len; $i++ ) {
			if ( $levels[ $i ] > $levels[ $i - 1 ] + 1 ) {
				$skips++;
			}
		}
		return $skips;
	}

	/**
	 * Count form inputs without associated labels.
	 *
	 * @return int
	 */
	public function validate_form_labels() {
		$hidden_types = array( 'hidden', 'submit', 'button', 'reset', 'image' );
		$inputs       = $this->doc->getElementsByTagName( 'input' );
		$unlabeled    = 0;

		// Collect all label[for] values for fast lookup
		$label_fors = array();
		$labels     = $this->doc->getElementsByTagName( 'label' );
		foreach ( $labels as $label ) {
			$for = $label->getAttribute( 'for' );
			if ( $for ) {
				$label_fors[ $for ] = true;
			}
		}

		foreach ( $inputs as $input ) {
			$type = strtolower( $input->getAttribute( 'type' ) ?: 'text' );
			if ( in_array( $type, $hidden_types, true ) ) {
				continue;
			}

			$has_aria = $input->hasAttribute( 'aria-label' ) || $input->hasAttribute( 'aria-labelledby' );
			if ( $has_aria ) {
				continue;
			}

			$id = $input->getAttribute( 'id' );
			if ( ! $id || ! isset( $label_fors[ $id ] ) ) {
				$unlabeled++;
			}
		}

		return $unlabeled;
	}

	/**
	 * Count duplicate IDs on the page.
	 *
	 * @return int Number of IDs that appear more than once.
	 */
	public function validate_duplicate_ids() {
		$nodes = $this->xpath->query( '//*[@id]' );
		$ids   = array();
		foreach ( $nodes as $node ) {
			$id = $node->getAttribute( 'id' );
			if ( $id ) {
				$ids[] = $id;
			}
		}

		$counts = array_count_values( $ids );
		$dupes  = 0;
		foreach ( $counts as $count ) {
			if ( $count > 1 ) {
				$dupes++;
			}
		}
		return $dupes;
	}

	/**
	 * Count common form fields missing autocomplete attribute.
	 *
	 * @return int
	 */
	public function validate_autocomplete() {
		$field_names = array( 'email', 'name', 'nome', 'phone', 'telefone', 'tel', 'address', 'endereco', 'cep', 'cpf', 'password', 'senha' );
		$inputs      = $this->doc->getElementsByTagName( 'input' );
		$missing     = 0;

		foreach ( $inputs as $input ) {
			$name = strtolower( $input->getAttribute( 'name' ) );
			if ( ! $name ) {
				continue;
			}
			foreach ( $field_names as $field ) {
				if ( false !== strpos( $name, $field ) ) {
					if ( ! $input->hasAttribute( 'autocomplete' ) ) {
						$missing++;
					}
					break;
				}
			}
		}

		return $missing;
	}

	/**
	 * Count excess H1 elements (more than 1).
	 *
	 * @return int 0 if ≤1 H1, or (count - 1) if multiple.
	 */
	public function validate_multiple_h1() {
		$h1s = $this->doc->getElementsByTagName( 'h1' );
		return $h1s->length > 1 ? $h1s->length - 1 : 0;
	}

	// ─── Scoring Helpers ────────────────────────────────────────────────

	/**
	 * Calculate penalty for a given impact level and occurrence count.
	 *
	 * @param string $impact Impact level.
	 * @param int    $count  Number of occurrences.
	 * @return int
	 */
	public static function calculate_penalty( $impact, $count ) {
		$w = isset( self::$weights[ $impact ] ) ? self::$weights[ $impact ] : 1;
		$c = isset( self::$caps[ $impact ] ) ? self::$caps[ $impact ] : 5;
		return min( $w * $count, $c );
	}

	/**
	 * Calculate final score (0-100) from a penalty total.
	 *
	 * @param int $penalty Total penalty.
	 * @return int
	 */
	public static function calculate_score( $penalty ) {
		return max( 0, min( 100, 100 - $penalty ) );
	}
}
