"""Generic task handler driven by JSON definitions."""

from __future__ import annotations

import asyncio
import fnmatch
import hashlib
import json
import logging
import re
from collections.abc import Iterable, Mapping, MutableMapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import urljoin

import httpx
import jmespath
from parsel import Selector
from parsel.selector import SelectorList
from yt_dlp.utils.networking import random_user_agent

from app.library.cache import Cache
from app.library.config import Config
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
from app.library.Utils import extract_info, get_archive_id

from ._base_handler import BaseHandler

if TYPE_CHECKING:
    from parsel.selector import SelectorList

LOG: logging.Logger = logging.getLogger(__name__)
CACHE: Cache = Cache()

@dataclass(slots=True)
class MatchRule:
    """Represents a single URL matcher compiled to regex."""

    source: str
    """Original source pattern (regex or glob)."""

    regex: re.Pattern[str]
    """Compiled regex pattern."""

    @classmethod
    def from_value(cls, value: str | Mapping[str, Any]) -> MatchRule | None:
        """
        Create a MatchRule from a string or mapping.

        Args:
            value (str|Mapping[str, Any]): A string (treated as glob) or a mapping with 'regex' or 'glob' keys.

        Returns:
            (MatchRule|None): A MatchRule instance if successful, None otherwise.

        """
        if isinstance(value, Mapping):
            pattern: str | None = value.get("regex")
            glob_pattern: str | None = value.get("glob")
            raw: str | None = None

            if isinstance(pattern, str) and pattern:
                raw = pattern
                try:
                    compiled: re.Pattern[str] = re.compile(pattern)
                except re.error as exc:
                    LOG.error(f"Invalid regex pattern '{pattern}': {exc}")
                    return None

                return cls(source=raw, regex=compiled)

            if isinstance(glob_pattern, str) and glob_pattern:
                raw = glob_pattern
                compiled = re.compile(fnmatch.translate(glob_pattern))
                return cls(source=raw, regex=compiled)

            LOG.error("Matcher mapping must include 'regex' or 'glob' key with a string value.")
            return None

        if not isinstance(value, str) or not value:
            LOG.error(f"Matcher value must be a non-empty string, got '{value!r}'.")
            return None

        # Treat plain string as glob pattern for convenience.
        compiled = re.compile(fnmatch.translate(value))
        return cls(source=value, regex=compiled)

    def matches(self, url: str) -> bool:
        """
        Check if the given URL matches this rule.

        Args:
            url (str): The URL to check.

        Returns:
            (bool): True if the URL matches, False otherwise.

        """
        return bool(self.regex.match(url))


@dataclass(slots=True)
class PostFilter:
    """Regex post-filter applied on extracted values."""

    pattern: re.Pattern[str]
    """Compiled regex pattern."""

    value_key: str | None = None
    """Optional group name or index to extract from the match."""

    @classmethod
    def from_mapping(cls, mapping: Mapping[str, Any]) -> PostFilter | None:
        """
        Create a PostFilter from a mapping.

        Args:
            mapping (Mapping[str,Any]): A mapping with 'filter' (regex pattern) and optional 'value' (group name or index) keys.

        Returns:
            (PostFilter|None): A PostFilter instance if successful, None otherwise.

        """
        pattern: str | None = mapping.get("filter")
        if not isinstance(pattern, str) or not pattern:
            LOG.error("post_filter requires a non-empty 'filter' string.")
            return None

        try:
            compiled: re.Pattern[str] = re.compile(pattern)
        except re.error as exc:
            LOG.error(f"Invalid post_filter regex '{pattern}': {exc}")
            return None

        value_key: str | None = mapping.get("value")
        if value_key is not None and not isinstance(value_key, str):
            LOG.error("post_filter 'value' must be a string if provided.")
            return None

        return cls(pattern=compiled, value_key=value_key)

    def apply(self, candidate: str) -> str | None:
        """
        Apply the post-filter to the candidate string.

        Args:
            candidate (str): The string to filter.

        Returns:
            (str|None): The filtered value if matched, None otherwise.

        """
        match: re.Match[str] | None = self.pattern.search(candidate)
        if not match:
            return None

        if self.value_key:
            try:
                return match.group(self.value_key)
            except IndexError:
                LOG.warning(
                    f"post_filter value index '{self.value_key}' not present in pattern {self.pattern.pattern!r}."
                )
            except KeyError:
                LOG.warning(
                    f"post_filter value key '{self.value_key}' not present in pattern {self.pattern.pattern!r}."
                )
            return None

        if match.groupdict():
            # Prefer first named group when available.
            key, value = next(iter(match.groupdict().items()))
            if value is not None:
                LOG.debug(f"post_filter using named group '{key}'.")
                return value

        if match.groups():
            return match.group(1)

        return match.group(0)


@dataclass(slots=True)
class ExtractionRule:
    """Single field extraction description."""

    type: Literal["css", "xpath", "regex"]
    """Type of extraction to perform."""

    expression: str
    """CSS selector, XPath expression or regex pattern."""

    attribute: str | None = None
    """Optional attribute to extract (e.g. 'href', 'src', 'text', etc.)."""

    post_filter: PostFilter | None = None
    """Optional post-filter to apply on extracted values."""


@dataclass(slots=True)
class EngineConfig:
    """Engine selection to fetch the page."""

    type: Literal["httpx", "selenium"] = "httpx"
    """Engine type to use."""

    options: dict[str, Any] = field(default_factory=dict)
    """Engine-specific options."""


@dataclass(slots=True)
class RequestConfig:
    """HTTP request configuration."""

    method: str = "GET"
    """HTTP method to use."""
    headers: dict[str, str] = field(default_factory=dict)
    """HTTP headers to include."""
    params: dict[str, Any] = field(default_factory=dict)
    """Query parameters to include."""

    data: Any | None = None
    """Request body data to include."""
    json: Any | None = None
    """Request body JSON data to include."""
    timeout: float | None = None
    """Request timeout in seconds."""
    url: str | None = None
    """Optional URL to use instead of the task URL."""

    def normalized_method(self) -> str:
        """
        Get the HTTP method in uppercase.

        Returns:
            (str): The HTTP method in uppercase.

        """
        return self.method.upper() if isinstance(self.method, str) else "GET"


@dataclass(slots=True)
class ResponseConfig:
    """Defines how to interpret the response body returned by the fetch engine."""

    format: Literal["html", "json"] = "html"
    """Body format. Defaults to HTML."""


@dataclass(slots=True)
class ContainerDefinition:
    """Defines a repeating element with nested field extraction."""

    selector_type: Literal["css", "xpath", "jsonpath"]
    """Type of selector to use for locating container elements."""

    selector: str
    """Selector expression for locating container elements."""

    fields: dict[str, ExtractionRule]
    """Field extraction rules relative to the container."""


@dataclass(slots=True)
class TaskDefinition:
    """Full task definition as loaded from disk."""

    name: str
    """Human-readable name of the task definition."""
    source: Path
    """Path to the source JSON file."""
    matchers: list[MatchRule]
    """List of URL matchers."""
    engine: EngineConfig
    """Engine configuration."""
    request: RequestConfig
    """Request configuration."""
    parsers: dict[str, ExtractionRule]
    """Field extraction rules."""
    container: ContainerDefinition | None = None
    """Optional container definition for repeating elements."""
    response: ResponseConfig = field(default_factory=ResponseConfig)
    """Response configuration."""

    def matches(self, url: str) -> bool:
        """
        Check if the given URL matches any of the defined matchers.

        Args:
            url (str): The URL to check.

        Returns:
            (bool): True if any matcher matches the URL, False otherwise.

        """
        return any(rule.matches(url) for rule in self.matchers)


def _build_extraction_rule(field: str, raw: Mapping[str, Any], *, source: Path) -> ExtractionRule | None:
    """
    Build an ExtractionRule from a raw mapping.

    Args:
        field (str): The name of the field being defined.
        raw (Mapping[str, Any]): The raw mapping defining the extraction rule.
        source (Path): Path to the source JSON file for logging context.

    Returns:
        (ExtractionRule|None): An ExtractionRule instance if successful, None otherwise.

    """
    type_value: str | None = raw.get("type")
    expression: str | None = raw.get("expression")

    if not isinstance(type_value, str):
        LOG.error(f"[{source.name}] Field '{field}' is missing a valid 'type'.")
        return None

    if type_value not in {"css", "xpath", "regex", "jsonpath"}:
        LOG.error(f"[{source.name}] Field '{field}' has unsupported type '{type_value}'.")
        return None

    if not isinstance(expression, str) or not expression:
        LOG.error(f"[{source.name}] Field '{field}' requires non-empty 'expression'.")
        return None

    attribute: str | None = raw.get("attribute")
    if attribute is not None and not isinstance(attribute, str):
        LOG.error(f"[{source.name}] Field '{field}' attribute must be a string if provided.")
        return None

    post_filter: PostFilter | None = None
    if isinstance(raw.get("post_filter"), Mapping):
        post_filter = PostFilter.from_mapping(raw["post_filter"])

    return ExtractionRule(type=type_value, expression=expression, attribute=attribute, post_filter=post_filter)


def _build_matchers(raw_match: Iterable[Any], *, source: Path) -> list[MatchRule]:
    """
    Build a list of MatchRule instances from raw match definitions.

    Args:
        raw_match (Iterable[Any]): An iterable of raw match definitions (strings or mappings).
        source (Path): Path to the source JSON file for logging context.

    Returns:
        (list[MatchRule]): A list of MatchRule instances.

    """
    matchers: list[MatchRule] = []
    for value in raw_match:
        rule: MatchRule | None = MatchRule.from_value(value)
        if rule:
            matchers.append(rule)

    if not matchers:
        LOG.error(f"[{source.name}] No valid match rules found.")

    return matchers


def _normalize_mapping(value: Any) -> MutableMapping[str, Any]:
    """
    Ensure the value is a mutable mapping.

    Args:
        value (Any): The value to check.

    Returns:
        (MutableMapping[str, Any]): The value if it's a mutable mapping.

    """
    if isinstance(value, MutableMapping):
        return value

    msg = "Expected a mapping for parse/request/engine sections."
    raise ValueError(msg)


def load_task_definitions(config: Config | None = None) -> list[TaskDefinition]:
    """
    Load JSON task definitions from the configured tasks directory.

    Args:
        config (Config|None): Optional Config instance. If None, the singleton instance is used.

    Returns:
        (list[TaskDefinition]): A list of loaded TaskDefinition instances.

    """
    cfg: Config = config or Config.get_instance()
    tasks_dir: Path = Path(cfg.config_path) / "tasks"

    if not tasks_dir.exists():
        return []

    definitions: list[TaskDefinition] = []

    for path in sorted(tasks_dir.glob("*.json")):
        try:
            content: str = path.read_text(encoding="utf-8")
        except Exception as exc:
            LOG.error(f"Failed to read task configuration '{path}': {exc}")
            continue

        try:
            raw = json.loads(content)
        except Exception as exc:
            LOG.error(f"Failed to parse JSON for '{path}': {exc}")
            continue

        if not isinstance(raw, Mapping):
            LOG.error(f"Task definition in '{path}' must be a JSON object.")
            continue

        name: str | None = raw.get("name")
        if not isinstance(name, str) or not name.strip():
            LOG.error(f"Task definition '{path}' missing a valid 'name'.")
            continue

        match_value: list[str] | None = raw.get("match")
        if not isinstance(match_value, Iterable) or isinstance(match_value, (str, bytes)):
            LOG.error(f"[{path.name}] 'match' must be a list of patterns.")
            continue

        matchers: list[MatchRule] = _build_matchers(match_value, source=path)
        if not matchers:
            continue

        engine_raw: Any = raw.get("engine", {})
        try:
            engine_map: MutableMapping[str, Any] = _normalize_mapping(engine_raw)
        except ValueError:
            LOG.error(f"[{path.name}] 'engine' must be a JSON object when provided.")
            continue

        engine_type: str | None = engine_map.get("type", "httpx")
        if engine_type not in ("httpx", "selenium"):
            LOG.error(f"[{path.name}] Unsupported engine type '{engine_type}'.")
            continue

        engine_options: Any = engine_map.get("options") if isinstance(engine_map.get("options"), Mapping) else {}
        engine = EngineConfig(type=engine_type, options=dict(engine_options))

        request_raw: Any = raw.get("request", {})
        try:
            request_map: MutableMapping[str, Any] = _normalize_mapping(request_raw)
        except ValueError:
            LOG.error(f"[{path.name}] 'request' must be a JSON object when provided.")
            continue

        request = RequestConfig(
            method=str(request_map.get("method", "GET")),
            headers=dict(request_map.get("headers", {})) if isinstance(request_map.get("headers"), Mapping) else {},
            params=dict(request_map.get("params", {})) if isinstance(request_map.get("params"), Mapping) else {},
            data=request_map.get("data"),
            json=request_map.get("json"),
            timeout=float(request_map.get("timeout")) if request_map.get("timeout") is not None else None,
            url=str(request_map.get("url")) if isinstance(request_map.get("url"), str) else None,
        )

        response_raw: Any = raw.get("response", {})
        response_config = ResponseConfig()
        if response_raw:
            if not isinstance(response_raw, Mapping):
                LOG.error(f"[{path.name}] 'response' must be an object when provided.")
                continue

            response_type: str = str(response_raw.get("type", "html")).lower()
            if response_type not in ("html", "json"):
                LOG.error(f"[{path.name}] Unsupported response type '{response_type}'.")
                continue

            response_config = ResponseConfig(format=response_type)  # type: ignore[arg-type]

        parse_raw: Mapping | None = raw.get("parse")
        if not isinstance(parse_raw, Mapping):
            LOG.error(f"[{path.name}] 'parse' must be a JSON object mapping fields to instructions.")
            continue

        container_definition: ContainerDefinition | None = None
        parsers: dict[str, ExtractionRule] = {}

        items_block: Mapping | None = parse_raw.get("items")
        if isinstance(items_block, Mapping):
            raw_fields: Mapping | None = items_block.get("fields")
            if not isinstance(raw_fields, Mapping):
                LOG.error(f"[{path.name}] 'items.fields' must be a mapping of field definitions.")
                continue

            container_fields: dict[str, ExtractionRule] = {}
            for _field, rule in raw_fields.items():
                if not isinstance(_field, str):
                    LOG.error(f"[{path.name}] Container field names must be strings, got {_field!r}.")
                    continue

                if not isinstance(rule, Mapping):
                    LOG.error(f"[{path.name}] Container definition for '{_field}' must be an object.")
                    continue

                extraction_rule: ExtractionRule | None = _build_extraction_rule(_field, rule, source=path)
                if extraction_rule:
                    container_fields[_field] = extraction_rule

            if "link" not in container_fields:
                LOG.error(f"[{path.name}] Container definition is missing required 'link' field.")
                continue

            selector_value: str | None = items_block.get("selector") or items_block.get("expression")
            if not isinstance(selector_value, str) or not selector_value:
                LOG.error(f"[{path.name}] 'items.selector' must be a non-empty string.")
                continue

            selector_type = str(items_block.get("type", "css"))
            if selector_type not in ("css", "xpath", "jsonpath"):
                LOG.error(f"[{path.name}] Unsupported container selector type '{selector_type}'.")
                continue

            container_definition = ContainerDefinition(
                selector_type=selector_type,
                selector=selector_value,
                fields=container_fields,
            )

        for _field, rule in parse_raw.items():
            if "items" == _field:
                continue

            if not isinstance(_field, str):
                LOG.error(f"[{path.name}] Parser field names must be strings, got {_field!r}.")
                continue

            if not isinstance(rule, Mapping):
                LOG.error(f"[{path.name}] Parser definition for '{_field}' must be an object.")
                continue

            extraction_rule = _build_extraction_rule(_field, rule, source=path)
            if extraction_rule:
                parsers[_field] = extraction_rule

        if container_definition is None and "link" not in parsers:
            LOG.error(f"[{path.name}] Missing required 'link' parser definition.")
            continue

        definition = TaskDefinition(
            name=name.strip(),
            source=path,
            matchers=matchers,
            engine=engine,
            request=request,
            parsers=parsers,
            container=container_definition,
            response=response_config,
        )

        definitions.append(definition)

    return definitions


class GenericTaskHandler(BaseHandler):
    """Handler that scrapes arbitrary web pages based on JSON task definitions."""

    _definitions: list[TaskDefinition] = []
    """Cached loaded task definitions."""

    _sources_mtime: dict[Path, float] = {}
    """Modification times of source files to detect changes."""

    @classmethod
    def _tasks_dir(cls) -> Path:
        """
        Get the path to the tasks directory.

        Returns:
            (Path): Path to the tasks directory.

        """
        return Path(Config.get_instance().config_path) / "tasks"

    @classmethod
    def _refresh_definitions(cls, force: bool = False) -> None:
        """
        Refresh the cached task definitions if source files have changed.

        Args:
            force (bool): If True, force reload even if no changes detected.

        """
        tasks_dir: Path = cls._tasks_dir()

        if not tasks_dir.exists():
            if cls._definitions or cls._sources_mtime:
                cls._definitions = []
                cls._sources_mtime = {}
            return

        current: dict[Path, float] = {}
        for path in tasks_dir.glob("*.json"):
            try:
                current[path] = path.stat().st_mtime
            except OSError:
                LOG.warning(f"Unable to stat task definition '{path}'.")

        if force or not cls._definitions or current != cls._sources_mtime:
            cls._definitions = load_task_definitions()
            cls._sources_mtime = current

    @classmethod
    def refresh_definitions(cls, force: bool = False) -> None:
        """Public helper to refresh cached task definitions."""
        cls._refresh_definitions(force=force)

    @classmethod
    def _find_definition(cls, url: str) -> TaskDefinition | None:
        """
        Find a task definition that matches the given URL.

        Args:
            url (str): The URL to match.

        Returns:
            (TaskDefinition|None): A matching TaskDefinition if found, None otherwise.

        """
        cls._refresh_definitions()

        for definition in cls._definitions:
            try:
                if definition.matches(url):
                    return definition
            except Exception as exc:
                LOG.error(f"Error while matching definition '{definition.name}': {exc}")

        return None

    @staticmethod
    def can_handle(task: Task) -> bool:
        """
        Determine if this handler can process the given task.

        Args:
            task (Task): The task to check.

        Returns:
            (bool): True if the handler can process the task, False otherwise.

        """
        definition: TaskDefinition | None = GenericTaskHandler._find_definition(task.url)
        if definition:
            LOG.debug(f"'{task.name}': Matched generic task definition '{definition.name}'.")
            return True

        return False

    @staticmethod
    async def extract(task: Task, config: Config | None = None) -> TaskResult | TaskFailure:  # noqa: ARG004
        definition: TaskDefinition | None = GenericTaskHandler._find_definition(task.url)
        if not definition:
            return TaskFailure(message="No generic task definition matched the provided URL.")

        ytdlp_opts: dict[str, Any] = task.get_ytdlp_opts().get_all()
        target_url: str = definition.request.url or task.url

        LOG.debug(f"{task.name!r}: Fetching '{target_url}' using engine '{definition.engine.type}'.")

        try:
            body_text, json_data = await GenericTaskHandler._fetch_content(
                url=target_url, definition=definition, ytdlp_opts=ytdlp_opts
            )
        except Exception as exc:
            LOG.exception(exc)
            return TaskFailure(message="Failed to fetch target URL.", error=str(exc))

        if "json" == definition.response.format and json_data is None:
            return TaskFailure(message="Expected JSON response but decoding failed.")

        if "json" != definition.response.format and not body_text:
            return TaskFailure(message="Received empty response body.")

        raw_items: list[dict[str, str]] = GenericTaskHandler._parse_items(
            definition=definition, html=body_text or "", base_url=target_url, json_data=json_data
        )

        task_items: list[TaskItem] = []

        def _generic_id(url):
            import os
            import urllib

            return urllib.parse.unquote(os.path.splitext(url.rstrip("/").split("/")[-1])[0])

        for entry in raw_items:
            if not isinstance(entry, dict):
                continue

            if not (url := entry.get("link") or entry.get("url")):
                continue

            idDict: str | None = get_archive_id(url=url)
            archive_id: str | None = idDict.get("archive_id")
            if not archive_id:
                cache_key: str = hashlib.sha256(f"{task.name}-{url}".encode()).hexdigest()
                if CACHE.has(cache_key):
                    archive_id = CACHE.get(cache_key)
                    if not archive_id:
                        continue
                else:
                    LOG.warning(
                        f"[{definition.name}]: '{task.name}': Unable to generate static archive id for '{url}' in feed. Doing real request to fetch yt-dlp archive id."
                    )

                    info = extract_info(
                        config=task.get_ytdlp_opts().get_all(),
                        url=url,
                        no_archive=True,
                        no_log=True,
                    )

                    if not info:
                        LOG.error(f"[{definition.name}]: '{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping.")
                        CACHE.set(cache_key, None)
                        continue

                    if not info.get("id") or not info.get("extractor_key"):
                        LOG.error(f"[{definition.name}]: '{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping.")
                        CACHE.set(cache_key, None)
                        continue

                    archive_id = f"{str(info.get('extractor_key', '')).lower()} {info.get('id')}"
                    CACHE.set(cache_key, archive_id)

            metadata: dict[str, str] = {
                k: v for k, v in entry.items() if k not in {"link", "url", "title", "published", "archive_id"}
            }

            task_items.append(
                TaskItem(
                    url=url,
                    title=entry.get("title"),
                    archive_id=archive_id,
                    metadata={"published": entry.get("published"), **metadata},
                )
            )

        return TaskResult(
            items=task_items,
            metadata={
                "definition": definition.name,
                "response_format": definition.response.format,
            },
        )

    @staticmethod
    async def _fetch_content(
        url: str,
        definition: TaskDefinition,
        ytdlp_opts: dict[str, Any],
    ) -> tuple[str | None, Any | None]:
        """
        Fetch the content of the given URL using the specified engine.

        Args:
            url (str): The URL to fetch.
            definition (TaskDefinition): The task definition specifying the engine and request details.
            ytdlp_opts (dict[str, Any]): yt-dlp options that may influence fetching

        Returns:
            (str|None): The fetched HTML content if successful, None otherwise.

        """
        if "selenium" == definition.engine.type:
            return await GenericTaskHandler._fetch_with_selenium(url=url, definition=definition)

        return await GenericTaskHandler._fetch_with_httpx(url=url, definition=definition, ytdlp_opts=ytdlp_opts)

    @staticmethod
    async def _fetch_with_httpx(
        url: str,
        definition: TaskDefinition,
        ytdlp_opts: dict[str, Any],
    ) -> tuple[str | None, Any | None]:
        """
        Fetch the content using httpx.

        Args:
            url (str): The URL to fetch.
            definition (TaskDefinition): The task definition specifying the request details.
            ytdlp_opts (dict[str, Any]): yt-dlp options that may influence fetching

        Returns:
            (str|None): The fetched HTML content if successful, None otherwise.

        """
        headers: dict[str, str] = {**definition.request.headers}
        client_options: dict[str, Any] = {
            "headers": {
                "User-Agent": random_user_agent(),
            }
        }

        try:
            from httpx_curl_cffi import AsyncCurlTransport, CurlOpt

            client_options["transport"] = AsyncCurlTransport(
                impersonate="chrome",
                default_headers=True,
                curl_options={CurlOpt.FRESH_CONNECT: True},
            )
            client_options["headers"].pop("User-Agent", None)
        except Exception:
            pass

        if headers:
            client_options["headers"].update(headers)

        if proxy := ytdlp_opts.get("proxy"):
            client_options["proxy"] = proxy

        timeout_value: float | Any = definition.request.timeout or ytdlp_opts.get("socket_timeout", 120)

        async with httpx.AsyncClient(**client_options) as client:
            response: httpx.Response = await client.request(
                method=definition.request.normalized_method(),
                url=url,
                params=definition.request.params or None,
                data=definition.request.data,
                json=definition.request.json,
                timeout=timeout_value,
            )
            response.raise_for_status()

            if "json" == definition.response.format:
                try:
                    json_data: dict[str, Any] = response.json()
                except Exception as exc:
                    LOG.error(f"Failed to decode JSON response from '{url}': {exc}")
                    return response.text, None

                return response.text, json_data

            return response.text, None

    @staticmethod
    async def _fetch_with_selenium(
        url: str,
        definition: TaskDefinition,
    ) -> tuple[str | None, Any | None]:
        """
        Fetch the content using a Selenium WebDriver.

        Args:
            url (str): The URL to fetch.
            definition (TaskDefinition): The task definition specifying the engine options.

        Returns:
            (str|None): The fetched HTML content if successful, None otherwise.

        """
        try:
            from selenium import webdriver
            from selenium.webdriver.chrome.options import Options as ChromeOptions
            from selenium.webdriver.common.by import By
            from selenium.webdriver.support import expected_conditions as EC
            from selenium.webdriver.support.ui import WebDriverWait
        except ImportError as exc:
            LOG.error(f"Selenium engine requested but selenium is not installed: {exc!s}.")
            return None

        options_map: dict[str, Any] = definition.engine.options
        command_executor: str | None = options_map.get("url", "http://localhost:4444/wd/hub")
        browser: str = str(options_map.get("browser", "chrome")).lower()

        if "chrome" != browser:
            LOG.error(f"Unsupported selenium browser '{browser}'. Only 'chrome' is supported.")
            return None

        arguments: list[str] | str = options_map.get("arguments", ["--headless", "--disable-gpu"])
        if isinstance(arguments, str):
            arguments = [arguments]

        wait_for: Mapping | None = (
            options_map.get("wait_for") if isinstance(options_map.get("wait_for"), Mapping) else None
        )
        wait_timeout = float(options_map.get("wait_timeout", 15))
        page_load_timeout = float(options_map.get("page_load_timeout", 60))

        def load_page() -> str | None:
            chrome_options = ChromeOptions()
            for arg in arguments:
                chrome_options.add_argument(str(arg))

            driver = webdriver.Remote(command_executor=command_executor, options=chrome_options)
            try:
                driver.set_page_load_timeout(page_load_timeout)
                driver.get(url)

                if wait_for:
                    wait_type: str = str(wait_for.get("type", "css")).lower()
                    expression: str | None = wait_for.get("expression") or wait_for.get("value")
                    if isinstance(expression, str) and expression:
                        locator = None
                        locator = (By.XPATH, expression) if "xpath" == wait_type else (By.CSS_SELECTOR, expression)
                        WebDriverWait(driver, wait_timeout).until(EC.presence_of_element_located(locator))

                return driver.page_source
            finally:
                driver.quit()

        html: str | None = await asyncio.to_thread(load_page)
        return html, None

    @staticmethod
    def _parse_items(
        definition: TaskDefinition,
        html: str,
        base_url: str,
        json_data: Any | None = None,
    ) -> list[dict[str, str]]:
        """
        Parse the HTML content and extract items based on the definition.

        Args:
            definition (TaskDefinition): The task definition specifying the parsers.
            html (str): The HTML content to parse.
            base_url (str): The base URL to resolve relative links.
            json_data (Any|None): The JSON data to parse if applicable.

        Returns:
            (list[dict[str, str]]): A list of extracted items as dictionaries.

        """
        if "json" == definition.response.format:
            return GenericTaskHandler._parse_json_items(definition, json_data, base_url)

        selector = Selector(text=html)

        if definition.container:
            return GenericTaskHandler._parse_with_container(
                definition=definition,
                selector=selector,
                html=html,
                base_url=base_url,
            )

        extracted: dict[str, list[str]] = {}

        for _field, rule in definition.parsers.items():
            values: list[str] = GenericTaskHandler._execute_rule(field=_field, selector=selector, html=html, rule=rule)
            extracted[_field] = values

        link_values: list[str] = extracted.get("link", [])
        if not link_values:
            LOG.debug(f"Definition '{definition.name}' produced no link values.")
            return []

        total_items: int = len(link_values)
        items: list[dict[str, str]] = []

        for index in range(total_items):
            entry: dict[str, str] = {}
            link_value: str = link_values[index]
            if not link_value:
                continue

            entry["link"] = urljoin(base_url, link_value)

            for _field, values in extracted.items():
                if "link" == _field:
                    continue

                value: str | None = values[index] if index < len(values) else None
                if value is None:
                    continue

                entry[_field] = value

            items.append(entry)

        return items

    @staticmethod
    def _parse_json_items(
        definition: TaskDefinition,
        json_data: Any | None,
        base_url: str,
    ) -> list[dict[str, str]]:
        if json_data is None:
            LOG.debug(f"Definition '{definition.name}' expects JSON but no data was parsed.")
            return []

        if definition.container:
            return GenericTaskHandler._parse_json_with_container(definition, json_data, base_url)

        items: list[dict[str, str]] = []
        entry: dict[str, str] = {}

        for _field, rule in definition.parsers.items():
            values: list[str] = GenericTaskHandler._execute_json_rule(_field, json_data, rule)
            if values:
                if "link" == _field:
                    entry["link"] = urljoin(base_url, values[0])
                else:
                    entry[_field] = values[0]

        if "link" in entry:
            items.append(entry)

        return items

    @staticmethod
    def _parse_with_container(
        definition: TaskDefinition,
        selector: Selector,
        html: str,
        base_url: str,
    ) -> list[dict[str, str]]:
        container: ContainerDefinition | None = definition.container
        if not container:
            return []

        if "jsonpath" == container.selector_type:
            LOG.error(
                f"Container selector type 'jsonpath' requires response type 'json'. Definition '{definition.name}'."
            )
            return []

        selection: SelectorList[Selector] = (
            selector.css(container.selector) if "css" == container.selector_type else selector.xpath(container.selector)
        )

        items: list[dict[str, str]] = []

        for node in selection:
            node_html: Any | str = node.get() or html
            entry: dict[str, str] = {}

            for _field, rule in container.fields.items():
                values: list[str] = GenericTaskHandler._execute_rule(
                    field=_field,
                    selector=node,
                    html=node_html,
                    rule=rule,
                )

                value: str | None = values[0] if values else None
                if value is None:
                    continue

                if "link" == _field:
                    entry["link"] = urljoin(base_url, value)
                else:
                    entry[_field] = value

            if "link" not in entry:
                continue

            items.append(entry)

        return items

    @staticmethod
    def _parse_json_with_container(
        definition: TaskDefinition,
        json_data: Any,
        base_url: str,
    ) -> list[dict[str, str]]:
        container: ContainerDefinition | None = definition.container
        if not container:
            return []

        if "jsonpath" != container.selector_type:
            LOG.error(f"JSON response requires container selector type 'jsonpath'. Definition '{definition.name}'.")
            return []

        nodes: Any = GenericTaskHandler._json_search(json_data, container.selector)
        if nodes is None:
            return []

        if not isinstance(nodes, list):
            nodes = [nodes]

        items: list[dict[str, str]] = []

        for node in nodes:
            entry: dict[str, str] = {}

            for _field, rule in container.fields.items():
                values: list[str] = GenericTaskHandler._execute_json_rule(_field, node, rule)
                if not values:
                    continue

                if "link" == _field:
                    entry["link"] = urljoin(base_url, values[0])
                else:
                    entry[_field] = values[0]

            if "link" not in entry:
                continue

            items.append(entry)

        return items

    @staticmethod
    def _execute_json_rule(field: str, data: Any, rule: ExtractionRule) -> list[str]:
        values: list[str] = []

        if "jsonpath" == rule.type:
            result: Any = GenericTaskHandler._json_search(data, rule.expression)
            candidates: list | list[Any] = result if isinstance(result, list) else [result]
            for candidate in candidates:
                if candidate is None:
                    continue

                text: str = GenericTaskHandler._coerce_to_string(candidate)
                processed: str | None = GenericTaskHandler._apply_post_filter(text, rule)
                if processed is not None:
                    values.append(processed)

            return values

        if "regex" == rule.type:
            target: str = GenericTaskHandler._coerce_to_string(data)
            try:
                pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL)
            except re.error as exc:
                LOG.error(f"Invalid regex expression '{rule.expression}': {exc}")
                return values

            for match in pattern.finditer(target):
                raw: str | None = GenericTaskHandler._regex_value(match=match, attribute=rule.attribute)
                processed = GenericTaskHandler._apply_post_filter(raw, rule)
                if processed is not None:
                    values.append(processed)

            return values

        LOG.error(f"Unsupported extraction type '{rule.type}' for JSON data in field '{field}'.")
        return values

    @staticmethod
    def _json_search(data: Any, expression: str) -> Any:
        try:
            return jmespath.search(expression, data)
        except Exception as exc:
            LOG.error(f"JSONPath search failed for expression '{expression}': {exc}")
            return None

    @staticmethod
    def _coerce_to_string(value: Any) -> str:
        if isinstance(value, str):
            return value
        if isinstance(value, (int, float, bool)) or value is None:
            return "" if value is None else str(value)
        try:
            return json.dumps(value, ensure_ascii=False)
        except Exception:
            return str(value)

    @staticmethod
    def _execute_rule(field: str, selector: Selector, html: str, rule: ExtractionRule) -> list[str]:
        """
        Execute a single extraction rule and return the list of extracted values.

        Args:
            field (str): The name of the field being extracted.
            selector (Selector): The parsel Selector for the HTML content.
            html (str): The raw HTML content.
            rule (ExtractionRule): The extraction rule to execute.

        Returns:
            (list[str]): A list of extracted values.

        """
        values: list[str] = []

        if "regex" == rule.type:
            try:
                pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL)
            except re.error as exc:
                LOG.error(f"Invalid regex expression '{rule.expression}': {exc}")
                return values

            for match in pattern.finditer(html):
                raw: str | None = GenericTaskHandler._regex_value(match=match, attribute=rule.attribute)
                processed: str | None = GenericTaskHandler._apply_post_filter(raw, rule)
                if processed is not None:
                    values.append(processed)

            return values

        if "jsonpath" == rule.type:
            LOG.error("Extraction type 'jsonpath' is only valid for JSON responses.")
            return values

        selection: SelectorList[Selector] = (
            selector.css(rule.expression) if "css" == rule.type else selector.xpath(rule.expression)
        )

        for sel in selection:
            raw = GenericTaskHandler._selector_value(field, sel, rule.attribute)
            processed = GenericTaskHandler._apply_post_filter(raw, rule)
            if processed is not None:
                values.append(processed)

        return values

    @staticmethod
    def _regex_value(match: re.Match[str], attribute: str | None) -> str | None:
        """
        Extract a value from a regex match based on the attribute.

        Args:
            match (re.Match[str]): The regex match object.
            attribute (str|None): Optional group name or index to extract.

        Returns:
            (str|None): The extracted value if found, None otherwise.

        """
        if attribute:
            try:
                return match.group(attribute)
            except (IndexError, KeyError):
                LOG.debug(f"Regex group '{attribute}' not found in pattern '{match.re.pattern}'.")
                return None

        if match.groupdict():
            return next((value for value in match.groupdict().values() if value), None)

        if match.groups():
            return match.group(1)

        return match.group(0)

    @staticmethod
    def _selector_value(field: str, sel: Selector, attribute: str | None) -> str | None:
        """
        Extract a value from a parsel Selector based on the attribute.

        Args:
            field (str): The name of the field being extracted.
            sel (Selector): The parsel Selector object.
            attribute (str|None): Optional attribute to extract (e.g. 'href', 'src', 'text', etc.).

        Returns:
            (str|None): The extracted value if found, None otherwise.

        """
        attr: str | None = attribute.lower() if isinstance(attribute, str) else None

        if attr in {"text", "inner_text"}:
            return sel.xpath("normalize-space()").get()

        if attr in {"html", "outer_html"}:
            value: Any = sel.get()
            return value if value is not None else None

        if attr and attr not in {"html", "outer_html", "text", "inner_text"}:
            try:
                attributes: dict[str, str] = sel.attrib
            except AttributeError:
                attributes = None

            if attributes and attr in attributes:
                return attributes.get(attr)

            attr_value: str | None = sel.xpath(f"@{attr}").get()
            if attr_value is not None:
                return attr_value

        if attr is None and "link" == field.lower():
            href = None
            try:
                attributes = sel.attrib
            except AttributeError:
                attributes = None

            if attributes and "href" in attributes:
                href: str | None = attributes.get("href")
            if not href:
                href: str | None = sel.xpath("@href").get()
            if href:
                return href

        if attr is None:
            text_value: str | None = sel.xpath("normalize-space()").get()
            if text_value:
                return text_value

        value = sel.get()
        return value if value is not None else None

    @staticmethod
    def _apply_post_filter(value: str | None, rule: ExtractionRule) -> str | None:
        """
        Apply the post-filter to the extracted value if defined.

        Args:
            value (str|None): The extracted value to filter.
            rule (ExtractionRule): The extraction rule containing the post-filter.

        Returns:
            (str|None): The filtered value if applicable, None otherwise.

        """
        if value is None:
            return None

        cleaned: str = value.strip()
        if rule.post_filter:
            return rule.post_filter.apply(cleaned)

        return cleaned or None
