<?php /** @noinspection HtmlDeprecatedTag */
namespace Gt\CssXPath;

class Translator {
	const cssRegex =
		'/'
		. '(?P<star>\*)'
		. '|(:(?P<pseudo>[\w-]*))'
		. '|\(*(?P<pseudospecifier>["\']*[\w\h-]*["\']*)\)'
		. '|(?P<element>[\w-]*)'
		. '|(?P<child>\s*>\s*)'
		. '|(#(?P<id>[\w-]*))'
		. '|(\.(?P<class>[\w-]*))'
		. '|(?P<sibling>\s*\+\s*)'
		. "|(\[(?P<attribute>[\w-]*)((?P<attribute_equals>[=~$*]+)(?P<attribute_value>(.+\[\]'?)|[^\]]+))*\])+"
		. '|(?P<descendant>\s+)'
		. '/';

	const EQUALS_EXACT = "=";
	const EQUALS_CONTAINS_WORD = "~=";
	const EQUALS_ENDS_WITH = "$=";
	const EQUALS_CONTAINS = "*=";
	const EQUALS_STARTS_WITH_OR_STARTS_WITH_HYPHENATED = "|=";
	const EQUALS_STARTS_WITH = "^=";

	/** @var string */
	protected $cssSelector;
	/** @var string */
	protected $prefix;

	public function __construct(string $cssSelector, string $prefix = ".//") {
		$this->cssSelector = $cssSelector;
		$this->prefix = $prefix;
	}

	public function __toString():string {
		return $this->asXPath();
	}

	public function asXPath():string {
		return $this->convert($this->cssSelector);
	}

	protected function convert(string $css):string {
		$cssArray = preg_split(
			'/(["\']).*?\1(*SKIP)(*F)|,/',
			$css
		);
		$xPathArray = [];

		foreach($cssArray as $input) {
			$output = $this->convertSingleSelector(trim($input));
			$xPathArray []= $output;
		}

		return implode(" | ", $xPathArray);
	}

	protected function convertSingleSelector(string $css):string {
		$thread = $this->preg_match_collated(self::cssRegex, $css);
		$thread = array_values($thread);

		$xpath = [$this->prefix];
		$hasElement = false;
		foreach($thread as $threadKey => $currentThreadItem) {
			$next = isset($thread[$threadKey + 1])
				? $thread[$threadKey + 1]
				: false;

			switch ($currentThreadItem["type"]) {
			case "star":
			case "element":
				$xpath []= $currentThreadItem['content'];
				$hasElement = true;
				break;

			case "pseudo":
				$specifier = "";
				if ($next && $next["type"] == "pseudospecifier") {
					$specifier = "{$next['content']}";
				}

				switch ($currentThreadItem["content"]) {
				case "disabled":
				case "checked":
				case "selected":
					array_push(
						$xpath,
						"[@{$currentThreadItem['content']}]"
					);
					break;

				case "text":
					array_push(
						$xpath,
						'[@type="text"]'
					);
					break;

				case "contains":
					if(empty($specifier)) {
						continue 3;
					}

					array_push(
						$xpath,
						"[contains(text(),$specifier)]"
					);
					break;

				case "first-child":
					$prev = count($xpath) - 1;
					$xpath[$prev] = '*[1]/self::' . $xpath[$prev];
					break;

				case "nth-child":
					if (empty($specifier)) {
						continue 3;
					}

					$prev = count($xpath) - 1;
					$previous = $xpath[$prev];

					if (substr($previous, -1, 1) === "]") {
						$xpath[$prev] = str_replace(
							"]",
							" and position() = $specifier]",
							$xpath[$prev]
						);
					}
					else {
						array_push(
							$xpath,
							"[$specifier]"
						);
					}
					break;
				case "nth-of-type":
					if (empty($specifier)) {
						continue 3;
					}

					$prev = count($xpath) - 1;
					$previous = $xpath[$prev];

					if(substr($previous, -1, 1) === "]") {
						array_push(
							$xpath,
							"[$specifier]"
						);
					}
					else {
						array_push(
							$xpath,
							"[$specifier]"
						);
					}
					break;
				}
				break;

			case "child":
				array_push($xpath, "/");
				$hasElement = false;
				break;

			case "id":
				array_push(
					$xpath,
					($hasElement ? '' : '*')
					. "[@id='{$currentThreadItem['content']}']"
				);
				$hasElement = true;
				break;

			case "class":
				// https://devhints.io/xpath#class-check
				array_push(
					$xpath,
					($hasElement ? '' : '*')
					. "[contains(concat(' ',normalize-space(@class),' '),' {$currentThreadItem['content']} ')]"
				);
				$hasElement = true;
				break;

			case "sibling":
				array_push(
					$xpath,
					"/following-sibling::*[1]/self::"
				);
				$hasElement = false;
				break;

			case "attribute":
				if(!$hasElement) {
					array_push($xpath, "*");
					$hasElement = true;
				}

				/** @var null|array<int, array<string, string>> $detail */
				$detail = $currentThreadItem["detail"] ?? null;
				$detailType = $detail[0] ?? null;
				$detailValue = $detail[1] ?? null;

				if(!$detailType
				|| $detailType["type"] !== "attribute_equals") {
					array_push(
						$xpath,
						"[@{$currentThreadItem['content']}]"
					);
					continue 2;
				}

				$valueString = trim(
					$detailValue["content"],
					" '\""
				);

				$equalsType = $detailType["content"];
				switch ($equalsType) {
				case self::EQUALS_EXACT:
					array_push(
						$xpath,
						"[@{$currentThreadItem['content']}=\"{$valueString}\"]"
					);
					break;

				case self::EQUALS_CONTAINS:
					array_push(
						$xpath,
						"[contains(@{$currentThreadItem['content']},\"{$valueString}\")]"
					);
					break;

				case self::EQUALS_CONTAINS_WORD:
					array_push(
						$xpath,
						"["
						. "contains("
						. "concat(\" \",@{$currentThreadItem['content']},\" \"),"
						. "concat(\" \",\"{$valueString}\",\" \")"
						. ")"
						. "]"
					);
					break;

				case self::EQUALS_STARTS_WITH_OR_STARTS_WITH_HYPHENATED:
					throw new NotYetImplementedException();

				case self::EQUALS_STARTS_WITH:
					throw new NotYetImplementedException();

				case self::EQUALS_ENDS_WITH:
					array_push(
						$xpath,
						"["
						. "substring("
						. "@{$currentThreadItem['content']},"
						. "string-length(@{$currentThreadItem['content']}) - "
						. "string-length(\"{$valueString}\") + 1)"
						. "=\"{$valueString}\""
						. "]"
					);
					break;
				}
				break;

			case "descendant":
				array_push($xpath, "//");
				$hasElement = false;
				break;
			}
		}

		return implode("", $xpath);
	}

	/** @return array<int, array<string, string>> */
	protected function preg_match_collated(
		string $regex,
		string $string,
		callable $transform = null
	):array {
		preg_match_all(
			$regex,
			$string,
			$matches,
			PREG_PATTERN_ORDER
		);

		$set = [];
		foreach($matches[0] as $k => $v) {
			if(!empty($v)) {
				$set[$k] = null;
			}
		}

		foreach($matches as $k => $m) {
			if(is_numeric($k)) {
				continue;
			}

			foreach($m as $i => $match) {
				if($match === "") {
					continue;
				}

				$toSet = null;

				if($transform) {
					$toSet = $transform($k, $match);
				}
				else {
					$toSet = ["type" => $k, "content" => $match];
				}

				if(!isset($set[$i])) {
					$set[$i] = $toSet;
				}
				else {
					if(!isset($set[$i]["detail"])) {
						$set[$i]["detail"] = [];
					}

					array_push($set[$i]["detail"], $toSet);
				}
			}
		}

		return $set;
	}
}
