"""
xmldev.validator
~~~~~~~~~~~~~~~~
Structural validation of a parsed XML tree against a Schema AST.
Produces Violation objects but does NOT modify the tree.
"""
from __future__ import annotations

import importlib
import re
from dataclasses import dataclass, field
from typing import Any

from dateutil.parser import parse as dateutil_parse
from lxml import etree

from .parser import _local_name, xpath_path
from .schema import AttrSpec, ChildSpec, ElementSpec, Schema


# ---------------------------------------------------------------------------
# Violation
# ---------------------------------------------------------------------------

@dataclass
class Violation:
    """A single structural validation failure."""

    code: str                    # e.g. "UNKNOWN_TAG", "MISSING_REQUIRED", …
    path: str                    # XPath-like path
    element: etree._Element | None = None
    message: str = ""
    extra: dict[str, Any] = field(default_factory=dict)

    def __str__(self) -> str:
        return f"[{self.code}] {self.path}: {self.message}"


# ---------------------------------------------------------------------------
# Validator
# ---------------------------------------------------------------------------

class Validator:
    """
    Walk a parsed XML tree and validate against the Schema AST.

    Usage::

        v = Validator(schema)
        violations = v.validate(root_element)
    """

    def __init__(self, schema: Schema) -> None:
        self.schema = schema

    def validate(self, root: etree._Element) -> list[Violation]:
        violations: list[Violation] = []
        self._validate_element(root, self.schema.root, root, violations)
        return violations

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    def _validate_element(
        self,
        elem: etree._Element,
        spec: ElementSpec,
        doc_root: etree._Element,
        violations: list[Violation],
    ) -> None:
        path = xpath_path(elem, doc_root)

        # 1. Attributes
        self._check_attrs(elem, spec, path, violations)

        # 2. Text content / type
        self._check_text(elem, spec, path, violations)

        # 3. Custom validator
        if spec.custom_validator:
            self._run_custom_validator(elem, spec.custom_validator, path, violations)

        # 4. Children
        self._check_children(elem, spec, doc_root, path, violations)

        # 5. Conditional rules (simple expression evaluator)
        self._check_conditional_rules(elem, spec, path, violations)

    def _check_attrs(
        self,
        elem: etree._Element,
        spec: ElementSpec,
        path: str,
        violations: list[Violation],
    ) -> None:
        # Check required attrs present
        for attr_name, attr_spec in spec.attrs.items():
            if attr_spec.required and attr_name not in elem.attrib:
                # Check aliases too
                found = any(a in elem.attrib for a in attr_spec.aliases)
                if not found:
                    violations.append(Violation(
                        code="MISSING_REQUIRED_ATTR",
                        path=f"{path}/@{attr_name}",
                        element=elem,
                        message=f"Required attribute '{attr_name}' missing",
                        extra={"attr": attr_name},
                    ))
                continue
            # Validate value if present
            for actual_name in [attr_name] + attr_spec.aliases:
                if actual_name in elem.attrib:
                    val = elem.attrib[actual_name]
                    err = self._check_value(val, attr_spec.type, attr_spec.enum_values, attr_spec.pattern)
                    if err:
                        violations.append(Violation(
                            code="ATTR_TYPE_ERROR",
                            path=f"{path}/@{actual_name}",
                            element=elem,
                            message=err,
                            extra={"attr": actual_name, "value": val},
                        ))

        # Check for unknown attributes
        known = set(spec.attrs.keys()) | {a for s in spec.attrs.values() for a in s.aliases}
        for attr in elem.attrib:
            if attr not in known:
                violations.append(Violation(
                    code="UNKNOWN_ATTR",
                    path=f"{path}/@{attr}",
                    element=elem,
                    message=f"Unknown attribute '{attr}'",
                    extra={"attr": attr},
                ))

    def _check_text(
        self,
        elem: etree._Element,
        spec: ElementSpec,
        path: str,
        violations: list[Violation],
    ) -> None:
        # Only validate text if the element has no children (leaf node)
        if len(elem) > 0:
            return
        text = (elem.text or "").strip()
        if not text:
            return
        err = self._check_value(text, spec.text_type, spec.enum_values, spec.pattern)
        if err:
            violations.append(Violation(
                code="TEXT_TYPE_ERROR",
                path=path,
                element=elem,
                message=err,
                extra={"value": text, "expected_type": spec.text_type},
            ))

    def _check_children(
        self,
        elem: etree._Element,
        spec: ElementSpec,
        doc_root: etree._Element,
        path: str,
        violations: list[Violation],
    ) -> None:
        child_specs_by_name: dict[str, ChildSpec] = {}
        alias_to_canonical: dict[str, str] = {}
        for cs in spec.children:
            child_specs_by_name[cs.spec.name] = cs
            for alias in cs.spec.aliases:
                child_specs_by_name[alias] = cs
                alias_to_canonical[alias] = cs.spec.name

        # Count actual children by tag name, mapping aliases to canonical name
        counts: dict[str, int] = {}
        actual_children = list(elem)
        for child in actual_children:
            local = _local_name(child.tag)
            # If the tag is an alias, count it under the canonical name
            canonical = alias_to_canonical.get(local, local)
            counts[canonical] = counts.get(canonical, 0) + 1

        # Check cardinality for each known child spec
        for cs in spec.children:
            name = cs.spec.name
            count = counts.get(name, 0)
            min_oc = cs.min_occurs
            max_oc = cs.max_occurs

            if count < min_oc:
                violations.append(Violation(
                    code="MISSING_REQUIRED",
                    path=f"{path}/{name}",
                    element=elem,
                    message=f"Element '{name}' must appear at least {min_oc} time(s); found {count}",
                    extra={"name": name, "min_occurs": min_oc, "count": count, "child_spec": cs},
                ))
            elif max_oc != "unbounded" and isinstance(max_oc, int) and count > max_oc:
                violations.append(Violation(
                    code="TOO_MANY",
                    path=f"{path}/{name}",
                    element=elem,
                    message=f"Element '{name}' must appear at most {max_oc} time(s); found {count}",
                    extra={"name": name, "max_occurs": max_oc, "count": count},
                ))

        # Unknown children: exclude tags known via canonical name OR alias
        global_cfg = self.schema.global_config
        for child in actual_children:
            local = _local_name(child.tag)
            if local not in child_specs_by_name:
                violations.append(Violation(
                    code="UNKNOWN_TAG",
                    path=xpath_path(child, doc_root),
                    element=child,
                    message=f"Unknown element '{local}'; policy is '{global_cfg.allow_unknown}'",
                    extra={"tag": local, "allow_unknown": global_cfg.allow_unknown},
                ))

        # Order check
        if spec.order_enforced:
            ordered_specs = sorted(
                [cs for cs in spec.children if cs.order_index is not None],
                key=lambda cs: cs.order_index,  # type: ignore[arg-type]
            )
            expected_order = [cs.spec.name for cs in ordered_specs]
            actual_order = [_local_name(c.tag) for c in actual_children if _local_name(c.tag) in child_specs_by_name]
            filtered_expected = [n for n in expected_order if n in actual_order]

            if filtered_expected and actual_order != filtered_expected:
                violations.append(Violation(
                    code="WRONG_ORDER",
                    path=path,
                    element=elem,
                    message=f"Children order incorrect. Expected {expected_order}, got {actual_order}",
                    extra={"expected": expected_order, "actual": actual_order},
                ))

        # Recurse into known children
        for child in actual_children:
            local = _local_name(child.tag)
            if local in child_specs_by_name:
                cs = child_specs_by_name[local]
                self._validate_element(child, cs.spec, doc_root, violations)

    def _check_conditional_rules(
        self,
        elem: etree._Element,
        spec: ElementSpec,
        path: str,
        violations: list[Violation],
    ) -> None:
        # Simple evaluator: expressions like "parent.status == 'active'"
        # We provide a limited safe context.
        for rule in spec.conditional_rules:
            try:
                ctx = _build_rule_context(elem)
                when_result = eval(rule.when, {"__builtins__": {}}, ctx)  # noqa: S307
                if when_result:
                    # Evaluate the "then" as an assertion or set required=true
                    then_result = eval(rule.then, {"__builtins__": {}}, ctx)  # noqa: S307
                    if then_result is False:
                        violations.append(Violation(
                            code="CONDITIONAL_RULE_FAILED",
                            path=path,
                            element=elem,
                            message=f"Conditional rule failed: when='{rule.when}' then='{rule.then}'",
                        ))
            except Exception:  # noqa: BLE001
                # Don't crash on expression evaluation errors; just skip
                pass

    @staticmethod
    def _check_value(
        value: str,
        type_: str,
        enum_values: list[str],
        pattern: str | None,
    ) -> str | None:
        """Return an error message string if *value* fails type check, else None."""
        if type_ == "string":
            return None
        if type_ == "int":
            try:
                int(value)
                return None
            except ValueError:
                return f"Expected integer, got '{value}'"
        if type_ == "float":
            try:
                float(value)
                return None
            except ValueError:
                return f"Expected float, got '{value}'"
        if type_ == "bool":
            if value.lower() in ("true", "false", "1", "0", "yes", "no"):
                return None
            return f"Expected boolean, got '{value}'"
        if type_ == "date":
            try:
                dateutil_parse(value)
                return None
            except Exception:
                return f"Expected date, got '{value}'"
        if type_ == "enum":
            if value in enum_values:
                return None
            return f"Value '{value}' not in enum {enum_values}"
        if type_ == "regex":
            if pattern and re.fullmatch(pattern, value):
                return None
            return f"Value '{value}' does not match pattern '{pattern}'"
        return None

    @staticmethod
    def _run_custom_validator(
        elem: etree._Element,
        validator_ref: str,
        path: str,
        violations: list[Violation],
    ) -> None:
        """Dynamically import and call a custom validator."""
        try:
            module_path, callable_name = validator_ref.rsplit(":", 1)
            mod = importlib.import_module(module_path)
            fn = getattr(mod, callable_name)
            result = fn(elem)
            if result is not None and result is not True:
                violations.append(Violation(
                    code="CUSTOM_VALIDATOR_FAILED",
                    path=path,
                    element=elem,
                    message=str(result),
                    extra={"validator": validator_ref},
                ))
        except Exception as exc:
            violations.append(Violation(
                code="CUSTOM_VALIDATOR_ERROR",
                path=path,
                element=elem,
                message=f"Custom validator '{validator_ref}' raised: {exc}",
            ))


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _build_rule_context(elem: etree._Element) -> dict[str, Any]:
    """Build a simple evaluation context for conditional rules."""
    parent = elem.getparent()

    class _Node:
        def __init__(self, el: etree._Element | None) -> None:
            self._el = el

        def __getattr__(self, name: str) -> str:
            if self._el is None:
                return ""
            child = self._el.find(name)
            if child is not None:
                return (child.text or "").strip()
            return self._el.get(name, "")

    return {
        "element": _Node(elem),
        "parent": _Node(parent),
        "text": (elem.text or "").strip(),
    }
