"""UKO ontology loader — lightweight TTL parser with no external RDF deps.

Parses ``@prefix``, node declarations (``subject a Type ;``),
``rdfs:subClassOf``, ``rdfs:label``, ``rdfs:comment``, ``rdfs:domain``,
``rdfs:range``, and ``owl:versionIRI`` from Turtle files into
:class:`UKOOntology` Pydantic models.
"""

from __future__ import annotations

import re
from collections import deque
from pathlib import Path
from typing import ClassVar

from cleveragents.domain.models.core.uko import (
    UKONode,
    UKOOntology,
    UKOPrefix,
    UKOVersion,
)

# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------


class UKOValidationError(Exception):
    """Raised when ontology validation fails."""


# ---------------------------------------------------------------------------
# Regex helpers
# ---------------------------------------------------------------------------

_PREFIX_RE = re.compile(
    r"^@prefix\s+([\w-]*)\s*:\s*<([^>]+)>\s*\.\s*$",
)
_IRI_RE = re.compile(r"<([^>]+)>")
_STRING_RE = re.compile(r'"([^"]*)"')

# Layer prefix patterns — maps prefixed-name prefixes to layer numbers.
# Uses the spec-defined semantic domain prefixes (not numeric indices).
_LAYER_PREFIXES: dict[str, int] = {
    "uko:": 0,
    "uko-code:": 1,
    "uko-doc:": 1,
    "uko-data:": 1,
    "uko-infra:": 1,
    "uko-oo:": 2,
    "uko-func:": 2,
    "uko-proc:": 2,
    "uko-py:": 3,
    "uko-ts:": 3,
    "uko-rs:": 3,
    "uko-java:": 3,
}

# Full-URI layer detection — maps IRI namespace prefixes to layers.
_LAYER_IRI_PREFIXES: dict[str, int] = {
    "https://cleveragents.ai/ontology/uko#": 0,
    "https://cleveragents.ai/ontology/uko/code#": 1,
    "https://cleveragents.ai/ontology/uko/doc#": 1,
    "https://cleveragents.ai/ontology/uko/data#": 1,
    "https://cleveragents.ai/ontology/uko/infra#": 1,
    "https://cleveragents.ai/ontology/uko/oo#": 2,
    "https://cleveragents.ai/ontology/uko/func#": 2,
    "https://cleveragents.ai/ontology/uko/proc#": 2,
    "https://cleveragents.ai/ontology/uko/py#": 3,
    "https://cleveragents.ai/ontology/uko/ts#": 3,
    "https://cleveragents.ai/ontology/uko/rs#": 3,
    "https://cleveragents.ai/ontology/uko/java#": 3,
}


def _detect_layer(uri: str) -> int:
    """Determine which layer (0-3) a URI belongs to based on prefix.

    Checks both prefixed names (``uko-code:Module``) and full IRIs
    (``https://cleveragents.ai/ontology/uko/code#Module``).
    """
    # Check prefixed names first (more common during parsing)
    for prefix, layer in _LAYER_PREFIXES.items():
        if uri.startswith(prefix):
            return layer
    # Check full IRIs
    for iri_prefix, layer in _LAYER_IRI_PREFIXES.items():
        if uri.startswith(iri_prefix):
            return layer
    return 0


def _extract_string(value: str) -> str:
    """Extract a quoted string value from a TTL predicate object."""
    match = _STRING_RE.search(value)
    return match.group(1) if match else value.strip().strip('"')


def _extract_iri(value: str) -> str:
    """Extract an IRI (<...>) from a TTL predicate object."""
    match = _IRI_RE.search(value)
    return match.group(1) if match else value.strip()


# ---------------------------------------------------------------------------
# Loader
# ---------------------------------------------------------------------------


class UKOLoader:
    """Load and validate UKO ontology from TTL files."""

    SUPPORTED_VERSIONS: ClassVar[tuple[str, ...]] = ("0.1.0",)

    def load(self, ttl_path: Path) -> UKOOntology:
        """Parse a TTL file into a :class:`UKOOntology` model."""
        text = ttl_path.read_text(encoding="utf-8")
        return self.load_from_string(text)

    def load_from_string(self, text: str) -> UKOOntology:
        """Parse a TTL string into a :class:`UKOOntology` model."""
        prefixes = self._parse_prefixes(text)
        prefix_map = {p.prefix: p.uri for p in prefixes}
        blocks = self._split_blocks(text)
        nodes: list[UKONode] = []
        version_iri = ""
        version_label = ""
        version_comment = ""

        for subject, predicates in blocks:
            props = self._parse_predicates(predicates)

            # Ontology declaration (subject is bare prefix like "uko:")
            if (
                props.get("a") == "owl:Ontology"
                or props.get("rdf:type") == "owl:Ontology"
            ):
                version_iri = props.get("owl:versionIRI", "")
                version_label = _extract_string(props.get("rdfs:label", ""))
                version_comment = _extract_string(props.get("rdfs:comment", ""))
                continue

            rdf_type = props.get("a", props.get("rdf:type", ""))
            if not rdf_type:
                continue

            raw_parents = props.get("rdfs:subClassOf")
            parent_uris: tuple[str, ...] = ()
            if raw_parents:
                # Handle comma-separated multi-parent values
                # e.g. "uko-code:TypeDefinition , uko:Container"
                parent_uris = tuple(
                    self._resolve_uri(p.strip(), prefix_map)
                    for p in raw_parents.split(",")
                    if p.strip()
                )
            label = _extract_string(props.get("rdfs:label", ""))
            comment = _extract_string(props.get("rdfs:comment", ""))

            extra: dict[str, str] = {}
            if "rdfs:domain" in props:
                extra["rdfs:domain"] = props["rdfs:domain"]
            if "rdfs:range" in props:
                extra["rdfs:range"] = props["rdfs:range"]
            if "rdfs:subPropertyOf" in props:
                extra["rdfs:subPropertyOf"] = props["rdfs:subPropertyOf"]

            # Resolve any prefixed property values to full URIs
            _resolvable_props = ("rdfs:domain", "rdfs:range", "rdfs:subPropertyOf")
            resolved_props: list[tuple[str, str]] = []
            for prop_key, prop_val in extra.items():
                if (
                    prop_key in _resolvable_props
                    and ":" in prop_val
                    and not prop_val.startswith("http")
                ):
                    resolved_props.append(
                        (prop_key, self._resolve_uri(prop_val, prefix_map)),
                    )
                else:
                    resolved_props.append((prop_key, prop_val))

            uri = self._resolve_uri(subject, prefix_map)
            layer = _detect_layer(subject)

            nodes.append(
                UKONode(
                    uri=uri,
                    rdf_type=rdf_type,
                    label=label,
                    comment=comment,
                    parent_uris=parent_uris,
                    layer=layer,
                    properties=tuple(resolved_props),
                ),
            )

        version_str = self._extract_version_string(version_iri)

        ontology = UKOOntology(
            version=UKOVersion(
                version_iri=version_iri,
                version=version_str,
                label=version_label,
                comment=version_comment,
            ),
            prefixes=tuple(prefixes),
            nodes=tuple(nodes),
        )

        errors = self.validate(ontology)
        if errors:
            raise UKOValidationError("; ".join(errors))

        return ontology

    # ------------------------------------------------------------------
    # Validation
    # ------------------------------------------------------------------

    def validate(self, ontology: UKOOntology) -> list[str]:
        """Validate ontology and return list of errors (empty = valid)."""
        errors: list[str] = []
        known_prefixes = {p.prefix for p in ontology.prefixes}
        node_uris = {n.uri for n in ontology.nodes}

        _uko_ns = "https://cleveragents.ai/ontology/uko"

        for node in ontology.nodes:
            if not node.rdf_type:
                errors.append(f"Node '{node.uri}' missing rdf:type")

            for resolved_parent in node.parent_uris:
                if resolved_parent.startswith("http://") or resolved_parent.startswith(
                    "https://"
                ):
                    # Internal UKO URIs must resolve to a known node.
                    # Guard with delimiter to avoid false matches on URIs
                    # that merely share the prefix string (e.g. "ukobogus").
                    is_internal = resolved_parent.startswith(
                        _uko_ns + "#"
                    ) or resolved_parent.startswith(_uko_ns + "/")
                    if is_internal and resolved_parent not in node_uris:
                        errors.append(
                            f"Node '{node.uri}' references "
                            f"non-existent parent '{resolved_parent}'"
                        )
                    # Truly external URIs (w3.org, etc.) are valid
                elif ":" in resolved_parent:
                    prefix_part = resolved_parent.split(":")[0]
                    if prefix_part not in known_prefixes:
                        errors.append(
                            f"Node '{node.uri}' references undefined "
                            f"prefix '{prefix_part}:' in parent_uri"
                        )
                    elif resolved_parent not in node_uris:
                        errors.append(
                            f"Node '{node.uri}' references "
                            f"non-existent parent '{resolved_parent}'"
                        )

            # Check for undefined prefixes in URI
            if ":" in node.uri and not node.uri.startswith("http"):
                prefix_part = node.uri.split(":")[0]
                if prefix_part not in known_prefixes:
                    errors.append(
                        f"Node '{node.uri}' uses undefined prefix '{prefix_part}:'"
                    )

        if ontology.version.version not in self.SUPPORTED_VERSIONS:
            errors.append(
                f"Unsupported version '{ontology.version.version}'; "
                f"supported: {', '.join(self.SUPPORTED_VERSIONS)}"
            )

        return errors

    # ------------------------------------------------------------------
    # Query helpers
    # ------------------------------------------------------------------

    def resolve_inheritance(
        self,
        ontology: UKOOntology,
        node_uri: str,
    ) -> list[UKONode]:
        """Resolve the inheritance DAG from *node_uri* up to root(s).

        Uses BFS (``collections.deque``) to traverse all
        ``rdfs:subClassOf`` parents, producing a list ordered leaf-first.
        Diamond inheritance is handled correctly — each ancestor appears
        at most once.  Raises :class:`UKOValidationError` on cycles.
        """
        uri_map: dict[str, UKONode] = {n.uri: n for n in ontology.nodes}

        # Phase 1: cycle detection via DFS with gray/black colouring
        self._detect_cycle_dfs(uri_map, node_uri)

        # Phase 2: BFS traversal for ordered ancestor list
        chain: list[UKONode] = []
        seen: set[str] = set()
        queue: deque[str] = deque()
        queue.append(node_uri)
        seen.add(node_uri)

        while queue:
            current_uri = queue.popleft()
            node = uri_map.get(current_uri)
            if node is None:
                continue
            chain.append(node)
            for parent in node.parent_uris:
                if parent not in seen:
                    seen.add(parent)
                    queue.append(parent)

        return chain

    def get_layer_nodes(
        self,
        ontology: UKOOntology,
        layer: int,
    ) -> list[UKONode]:
        """Return all nodes at a specific layer.

        .. note::
            This is O(n) per call.  For projects with 10,000+ indexed files
            (milestone v3.4.0), consider precomputing a ``dict[int,
            list[UKONode]]`` index during load.
        """
        # TODO(perf): precompute layer index when scaling demands it.
        return [n for n in ontology.nodes if n.layer == layer]

    # ------------------------------------------------------------------
    # Cycle detection
    # ------------------------------------------------------------------

    @staticmethod
    def _detect_cycle_dfs(
        uri_map: dict[str, UKONode],
        start_uri: str,
    ) -> None:
        """Raise :class:`UKOValidationError` if a cycle exists in the DAG.

        Uses iterative DFS with gray/black colouring to detect back-edges
        in the ``rdfs:subClassOf`` hierarchy reachable from *start_uri*.
        """
        # 0 = white (unvisited), 1 = gray (in progress), 2 = black (done)
        colour: dict[str, int] = {}
        stack: list[tuple[str, bool]] = [(start_uri, False)]

        while stack:
            uri, returning = stack.pop()

            if returning:
                colour[uri] = 2  # black — fully explored
                continue

            state = colour.get(uri, 0)
            if state == 2:
                continue  # already fully explored
            if state == 1:
                raise UKOValidationError(f"Cycle detected in inheritance chain: {uri}")

            colour[uri] = 1  # gray — in progress
            stack.append((uri, True))  # return marker

            node = uri_map.get(uri)
            if node is None:
                continue
            for parent in node.parent_uris:
                parent_state = colour.get(parent, 0)
                if parent_state == 1:
                    raise UKOValidationError(
                        f"Cycle detected in inheritance chain: {parent}"
                    )
                if parent_state == 0:
                    stack.append((parent, False))

    # ------------------------------------------------------------------
    # Internal parsing helpers
    # ------------------------------------------------------------------

    def _parse_prefixes(self, text: str) -> list[UKOPrefix]:
        """Parse ``@prefix`` lines from TTL text."""
        prefixes: list[UKOPrefix] = []
        for line in text.splitlines():
            match = _PREFIX_RE.match(line.strip())
            if match:
                prefixes.append(
                    UKOPrefix(prefix=match.group(1), uri=match.group(2)),
                )
        return prefixes

    def _split_blocks(self, text: str) -> list[tuple[str, str]]:
        """Split TTL text into (subject, predicates) blocks.

        Each block is a statement terminated by ``.`` (outside ``<>``, ``""``).
        Prefix declarations and comment-only lines are skipped.
        """
        lines: list[str] = []
        for raw_line in text.splitlines():
            stripped = raw_line.strip()
            if (
                not stripped
                or stripped.startswith("#")
                or stripped.startswith("@prefix")
            ):
                continue
            lines.append(stripped)

        joined = " ".join(lines)

        # Manually split on '.' that is a statement terminator, not inside
        # angle brackets or quotes.
        statements: list[str] = []
        buf: list[str] = []
        in_iri = False
        in_str = False
        i = 0
        chars = joined
        length = len(chars)
        while i < length:
            ch = chars[i]
            if ch == "<" and not in_str:
                in_iri = True
                buf.append(ch)
            elif ch == ">" and in_iri and not in_str:
                in_iri = False
                buf.append(ch)
            elif ch == '"' and not in_iri:
                in_str = not in_str
                buf.append(ch)
            elif ch == "." and not in_iri and not in_str:
                # Check it's a statement terminator (followed by whitespace or end)
                next_i = i + 1
                if next_i >= length or chars[next_i] in (" ", "\t", "\n", "\r"):
                    stmt = "".join(buf).strip()
                    if stmt:
                        statements.append(stmt)
                    buf = []
                else:
                    buf.append(ch)
            else:
                buf.append(ch)
            i += 1

        # Flush remaining buffer
        remainder = "".join(buf).strip()
        if remainder:
            statements.append(remainder)

        blocks: list[tuple[str, str]] = []
        for stmt in statements:
            parts = stmt.split(None, 1)
            if len(parts) < 2:
                continue
            blocks.append((parts[0], parts[1]))
        return blocks

    def _parse_predicates(self, predicates_str: str) -> dict[str, str]:
        """Parse ``predicate object ;`` pairs into a dict."""
        result: dict[str, str] = {}
        # Split on `;` that is outside `<>` and `""`
        pairs = self._split_on_semicolon(predicates_str)
        for pair in pairs:
            pair = pair.strip()
            if not pair:
                continue
            parts = pair.split(None, 1)
            if len(parts) < 2:
                continue
            predicate, obj = parts[0], parts[1]
            # Handle IRI values
            iri_match = _IRI_RE.search(obj)
            if iri_match and predicate in (
                "owl:versionIRI",
                "rdfs:domain",
                "rdfs:range",
                "rdfs:subPropertyOf",
            ):
                result[predicate] = iri_match.group(1)
            else:
                result[predicate] = obj.strip()
        return result

    @staticmethod
    def _split_on_semicolon(text: str) -> list[str]:
        """Split *text* on ``;`` that is not inside ``<>`` or ``""``."""
        parts: list[str] = []
        buf: list[str] = []
        in_iri = False
        in_str = False
        for ch in text:
            if ch == "<" and not in_str:
                in_iri = True
                buf.append(ch)
            elif ch == ">" and in_iri and not in_str:
                in_iri = False
                buf.append(ch)
            elif ch == '"' and not in_iri:
                in_str = not in_str
                buf.append(ch)
            elif ch == ";" and not in_iri and not in_str:
                parts.append("".join(buf))
                buf = []
            else:
                buf.append(ch)
        remainder = "".join(buf).strip()
        if remainder:
            parts.append(remainder)
        return parts

    def _resolve_uri(self, prefixed: str, prefix_map: dict[str, str]) -> str:
        """Expand a prefixed name to a full URI if the prefix is known."""
        if prefixed.startswith("<") and prefixed.endswith(">"):
            return prefixed[1:-1]
        if ":" in prefixed:
            prefix, local = prefixed.split(":", 1)
            if prefix in prefix_map:
                return prefix_map[prefix] + local
        return prefixed

    def _extract_version_string(self, version_iri: str) -> str:
        """Extract a semver-like version from a version IRI.

        E.g. ``https://ontology.cleverthis.com/uko/0.1.0`` -> ``0.1.0``.
        Returns ``""`` if the last path segment is not a valid semver string.
        """
        if not version_iri:
            return ""
        # Take the last path segment
        segment = version_iri.rstrip("/").rsplit("/", 1)[-1]
        if re.match(r"^\d+\.\d+\.\d+$", segment):
            return segment
        return ""
