"""Symbolic reasoning utilities for HIEROMEM."""

from __future__ import annotations

import logging
from collections import defaultdict
from collections import OrderedDict
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Iterable, Iterator, Mapping, Sequence

import networkx as nx
import numpy as np
from networkx.algorithms import isomorphism


_ACYCLIC_RELATIONS = {"parent_of", "located_in"}
_EQUIVALENCE_RELATIONS = {"same_as"}
_ANTI_SYMMETRIC_RELATIONS = {
    "parent_of",
    "ancestor_of",
    "manages",
    "supervises"}

_MUTUALLY_EXCLUSIVE_ATTRIBUTE_RULES: dict[str, list[set[str]]] = {
    "state": [
        {"alive", "deceased"},
        {"online", "offline"},
    ],
    "status": [
        {"active", "retired"},
        {"enabled", "disabled"},
    ],
    "polarity": [{"positive", "negative"}],
    "truth": [{"true", "false"}],
}

_DOMAIN_RELATION_CONFLICTS: list[tuple[str, str, str]] = [
    ("parent_of", "sibling_of", "parent_sibling_conflict"),
    ("mentor_of", "reports_to", "mentor_reporting_conflict"),
    ("located_in", "banned_from", "location_ban_conflict"),
    ("married_to", "divorced_from", "marital_status_conflict"),
    ("manages", "reports_to", "management_reporting_conflict"),
]

_DOMAIN_TYPE_CONSTRAINTS: list[dict[str, Any]] = [
    {
        "name": "parent_requires_person",
        "relation": "parent_of",
        "source_types": {"person", "human"},
        "target_types": {"person", "human"},
    },
    {
        "name": "employment_target_org",
        "relation": "works_at",
        "target_types": {"organisation", "organization", "company"},
    },
    {
        "name": "residence_target_place",
        "relation": "located_in",
        "target_types": {"location", "place", "city", "country"},
    },
]


_RELATION_CARDINALITY_CONSTRAINTS: list[dict[str, Any]] = [
    {
        "name": "monogamy_constraint",
        "relation": "married_to",
        "max_targets": 1,
    },
    {
        "name": "unique_headquarters",
        "relation": "headquartered_in",
        "max_targets": 1,
    },
]


def _normalise(values: Iterable[float]) -> np.ndarray:
    arr = np.asarray(list(values), dtype=float)
    if arr.size == 0:
        return arr
    total = float(np.sum(arr))
    if total == 0:
        return np.ones_like(arr) / len(arr)
    return arr / total


@lru_cache(maxsize=16384)
def _score_from_signatures(
    query_sig: GraphSignature, candidate_sig: GraphSignature, smoothing: float
) -> SymbolicScore:
    q_nodes = set(query_sig.nodes)
    c_nodes = set(candidate_sig.nodes)
    node_union = len(q_nodes | c_nodes)
    node_overlap = len(q_nodes & c_nodes) / (node_union + smoothing)

    q_edges = set(query_sig.edges)
    c_edges = set(candidate_sig.edges)
    edge_union = len(q_edges | c_edges)
    edge_overlap = len(q_edges & c_edges) / (edge_union + smoothing)

    q_depth = query_sig.depth
    c_depth = candidate_sig.depth
    structural_consistency = 1.0 - \
        abs(q_depth - c_depth) / (max(q_depth, c_depth, 1) + smoothing)

    return SymbolicScore(
        node_overlap=node_overlap,
        edge_overlap=edge_overlap,
        structural_consistency=structural_consistency,
    )


@dataclass(slots=True)
class ScoredPath:
    """A symbolic reasoning path with an explicit quality score."""
    path: Sequence[str]
    score: float
    trust_score: float
    semantic_score: float
    length_penalty: float
    explanation: str

    def to_dict(self) -> dict[str, Any]:
        return {
            "path": list(self.path),
            "score": self.score,
            "components": {
                "trust": self.trust_score,
                "semantic": self.semantic_score,
                "penalty": self.length_penalty
            },
            "explanation": self.explanation
        }


@dataclass(slots=True)
class SymbolicScore:
    """Summary of symbolic compatibility between a query and candidate graph."""

    node_overlap: float
    edge_overlap: float
    structural_consistency: float

    def combined(
            self,
            alpha: float = 0.5,
            beta: float = 0.3,
            gamma: float = 0.2) -> float:
        return float(
            alpha *
            self.node_overlap +
            beta *
            self.edge_overlap +
            gamma *
            self.structural_consistency)


@dataclass(frozen=True)
class GraphSignature:
    nodes: tuple[int, ...]
    edges: tuple[tuple[int, int], ...]
    depth: int


@dataclass(slots=True)
class _EdgeAttributeCandidate:
    """Candidate value for an edge attribute coming from a single source graph."""

    value: Any
    trust: float
    provenance: set[str]
    source: str | None
    priority: int


def _normalise_provenance(value: Any) -> set[str]:
    """Return provenance identifiers as a normalised set of strings."""

    if value is None:
        return set()
    if isinstance(value, (set, list, tuple)):
        return {str(item) for item in value if item is not None}
    return {str(value)}


class SymbolTable:
    """Assign stable integer ids to frequently used symbolic tokens."""

    def __init__(self) -> None:
        self._token_to_id: dict[str, int] = {}
        self._id_to_token: list[str] = []

    def get_id(self, token: str) -> int:
        key = str(token)
        if key not in self._token_to_id:
            idx = len(self._id_to_token)
            self._token_to_id[key] = idx
            self._id_to_token.append(key)
        return self._token_to_id[key]


class GraphReasoner:
    """Score candidate episodes against a symbolic query graph."""

    def __init__(
        self,
        smoothing: float = 1e-6,
        logger: logging.Logger | None = None,
        *,
        cache_size: int = 4096,
    ) -> None:
        self.smoothing = float(smoothing)
        self._logger = logger or logging.getLogger(self.__class__.__name__)
        self._symbols = SymbolTable()
        self._alignment_cache: OrderedDict[
            tuple[str, GraphSignature], SymbolicScore
        ] = OrderedDict()
        self._alignment_cache_size = max(128, int(cache_size))

    # ------------------------------------------------------------------ #
    def score(
            self,
            query: nx.DiGraph | None,
            candidate: nx.DiGraph | None) -> SymbolicScore:
        q_sig = self._signature(query)
        c_sig = self._signature(candidate)
        if not q_sig.nodes or not c_sig.nodes:
            return SymbolicScore(0.0, 0.0, 0.0)
        return _score_from_signatures(q_sig, c_sig, self.smoothing)

    # ------------------------------------------------------------------ #
    def batch_score(
        self,
        query: nx.DiGraph | None,
        candidates: Mapping[str, nx.DiGraph | None],
    ) -> dict[str, SymbolicScore]:
        query_sig = self._signature(query)
        results: dict[str, SymbolicScore] = {}
        for episode_id, graph in candidates.items():
            cache_key = (f"score:{episode_id}", query_sig)
            cached = self._alignment_cache.get(cache_key)
            if cached is not None:
                results[episode_id] = cached
                continue
            candidate_sig = self._signature(graph)
            if not query_sig.nodes or not candidate_sig.nodes:
                score = SymbolicScore(0.0, 0.0, 0.0)
            else:
                score = _score_from_signatures(
                    query_sig, candidate_sig, self.smoothing)
            self._remember_alignment(cache_key, score)
            results[episode_id] = score
        return results

    def _signature(self, graph: nx.DiGraph | None) -> GraphSignature:
        if graph is None or graph.number_of_nodes() == 0:
            return GraphSignature((), (), 0)
        node_ids = tuple(sorted(self._symbols.get_id(str(node))
                         for node in graph.nodes()))
        edge_ids = tuple(
            sorted(
                (self._symbols.get_id(str(u)), self._symbols.get_id(str(v)))
                for u, v in graph.edges()
            )
        )
        depth = self._graph_depth(graph)
        return GraphSignature(node_ids, edge_ids, depth)

    def _remember_alignment(self,
                            key: tuple[str,
                                       GraphSignature],
                            score: SymbolicScore) -> None:
        self._alignment_cache[key] = score
        self._alignment_cache.move_to_end(key)
        while len(self._alignment_cache) > self._alignment_cache_size:
            self._alignment_cache.popitem(last=False)

    # ------------------------------------------------------------------ #
    def has_path(self, graph: nx.DiGraph | None, path: Sequence[str]) -> bool:
        if graph is None or graph.number_of_nodes() == 0:
            return False
        if len(path) < 2:
            return False
        for src, dst in zip(path, path[1:]):
            if not graph.has_edge(src, dst):
                return False
        return True

    # ------------------------------------------------------------------ #
    # ------------------------------------------------------------------ #
    def score_path(
        self,
        graph: nx.DiGraph | None,
        path: Sequence[str],
        *,
        w_trust: float = 0.6,
        w_semantic: float = 0.4,
        decay: float = 0.9,
    ) -> ScoredPath:
        """
        Calculate an explicit quality score for a path.

        Score = (w_t * Trust + w_s * Semantic) * LengthPenalty

        where:
          - Trust: Product of edge trusts (or min/avg)
          - Semantic: Aggregate of edge weights/relevance
          - LengthPenalty: decay ^ (length - 1)
        """
        if graph is None or len(path) < 2:
            return ScoredPath(path, 0.0, 0.0, 0.0, 0.0, "Invalid path")

        edges_data = []
        for u, v in zip(path, path[1:]):
            if not graph.has_edge(u, v):
                return ScoredPath(path, 0.0, 0.0, 0.0, 0.0, f"Broken link: {u}->{v}")
            edges_data.append(graph.edges[u, v])

        # 1. Trust Component (Multiplicative to penalize weak links)
        # Default trust is 1.0 if missing
        trust_product = 1.0
        for data in edges_data:
            t = float(data.get("trust", 1.0))
            trust_product *= t

        # 2. Semantic Component (Average edge weight)
        # Assume 'weight' is 1.0 if missing
        total_weight = 0.0
        for data in edges_data:
            w = float(data.get("weight", 1.0))
            total_weight += w
        semantic_score = total_weight / len(edges_data)

        # 3. Length Penalty
        # decay^length is simpler
        length = len(edges_data)  # number of hops
        penalty = decay ** length

        # Compositional Score
        raw_score = (w_trust * trust_product + w_semantic * semantic_score)
        final_score = raw_score * penalty

        # Simple explanation
        rels = []
        for data in edges_data:
            rels.append(str(data.get("relation", "linked_to")))

        narrative_steps = []
        for i, node in enumerate(path[:-1]):
            rel = rels[i]
            next_node = path[i+1]
            narrative_steps.append(f"{node} --[{rel}]--> {next_node}")

        explanation = " then ".join(narrative_steps)

        return ScoredPath(
            path=path,
            score=final_score,
            trust_score=trust_product,
            semantic_score=semantic_score,
            length_penalty=penalty,
            explanation=explanation
        )

    def find_scored_paths(
        self,
        graph: nx.DiGraph | None,
        source: str,
        target: str,
        limit: int = 5,
        **score_kwargs
    ) -> list[ScoredPath]:
        """Find paths and return them scored and sorted."""
        raw_paths = self.find_paths(graph, source, target, limit=limit * 2)  # Fetch more to re-rank
        scored = []
        for p in raw_paths:
            scored.append(self.score_path(graph, p, **score_kwargs))

        # Sort by score descending
        scored.sort(key=lambda x: x.score, reverse=True)
        return scored[:limit]

    def find_paths(self, graph: nx.DiGraph | None, source: str,
                   target: str, limit: int = 5) -> list[list[str]]:
        if graph is None or graph.number_of_nodes() == 0:
            return []
        try:
            paths_iter: Iterator[list[str]] = nx.all_simple_paths(
                graph, source=source, target=target)
        except nx.NetworkXNoPath:
            return []
        except nx.NodeNotFound:
            return []
        results: list[list[str]] = []
        for path in paths_iter:
            results.append(path)
            if len(results) >= limit:
                break
        return results

    # ------------------------------------------------------------------ #
    def find_subgraphs(
        self,
        graph: nx.DiGraph | None,
        pattern: nx.DiGraph,
        *,
        limit: int = 5,
    ) -> list[dict[str, str]]:
        if graph is None or graph.number_of_nodes() == 0:
            return []
        matcher = isomorphism.DiGraphMatcher(
            graph,
            pattern,
            node_match=_attributes_match,
            edge_match=_attributes_match)
        matches: list[dict[str, str]] = []
        for mapping in matcher.subgraph_isomorphisms_iter():
            matches.append({str(pattern_node): str(graph_node)
                           for graph_node, pattern_node in mapping.items()})
            if len(matches) >= limit:
                break
        return matches

    # ------------------------------------------------------------------ #
    def merge_graphs(self, graphs: Iterable[Any]) -> nx.DiGraph:
        """Merge graphs while resolving conflicting edge semantics."""

        merged = nx.DiGraph()
        graph_entries = list(graphs)
        edge_attr_candidates: dict[tuple[str, str],
                                   dict[str, list[_EdgeAttributeCandidate]]] = {}
        edge_provenance: dict[tuple[str, str], set[str]] = {}
        merge_conflicts: list[dict[str, Any]] = []

        for priority, entry in enumerate(graph_entries):
            if entry is None:
                continue
            graph: nx.DiGraph
            metadata: Mapping[str, Any]
            if (
                isinstance(entry, tuple)
                and len(entry) == 2
                and isinstance(entry[0], nx.DiGraph)
                and isinstance(entry[1], Mapping)
            ):
                graph = entry[0]
                metadata = entry[1]
            elif isinstance(entry, nx.DiGraph):
                graph = entry
                metadata = {}
            else:
                continue

            if not isinstance(graph, nx.DiGraph):
                continue

            trust = float(metadata.get("trust", 0.5))
            source = metadata.get("episode_id") or metadata.get(
                "id") or metadata.get("source")
            provenance_hint = _normalise_provenance(metadata.get("provenance"))
            if not provenance_hint and source is not None:
                provenance_hint.add(str(source))

            for node, attrs in graph.nodes(data=True):
                merged.add_node(node)
                merged.nodes[node].update(attrs)

            for u, v, attrs in graph.edges(data=True):
                merged.add_edge(u, v)
                edge_key = (u, v)

                provenance = set(provenance_hint)
                provenance.update(
                    _normalise_provenance(
                        attrs.get("provenance")))
                if provenance:
                    edge_provenance.setdefault(
                        edge_key, set()).update(provenance)

                attr_map = edge_attr_candidates.setdefault(edge_key, {})
                for attr_key, value in attrs.items():
                    if attr_key == "provenance":
                        continue
                    attr_map.setdefault(attr_key, []).append(
                        _EdgeAttributeCandidate(
                            value=value,
                            trust=trust,
                            provenance=set(provenance),
                            source=str(source) if source is not None else None,
                            priority=priority,
                        )
                    )

        for (u, v), attr_map in edge_attr_candidates.items():
            for attr_key, candidates in attr_map.items():
                if not candidates:
                    continue

                unique_values: list[Any] = []
                for candidate in candidates:
                    if not any(
                            candidate.value == existing for existing in unique_values):
                        unique_values.append(candidate.value)

                chosen = sorted(
                    candidates,
                    key=lambda c: (-float(c.trust), -len(c.provenance), c.priority),
                )[0]

                merged.edges[u, v][attr_key] = chosen.value

                if len(unique_values) > 1:
                    conflict_record = {
                        "edge": (u, v),
                        "attribute": attr_key,
                        "candidates": [
                            {
                                "value": c.value,
                                "trust": float(c.trust),
                                "provenance": sorted(c.provenance),
                                "source": c.source,
                            }
                            for c in candidates
                        ],
                        "chosen": {
                            "value": chosen.value,
                            "trust": float(chosen.trust),
                            "provenance": sorted(chosen.provenance),
                            "source": chosen.source,
                        },
                    }
                    merge_conflicts.append(conflict_record)
                    candidate_details = ", ".join(
                        f"{c.source or f'graph[{c.priority}]'} → {c.value!r} "
                        f"(trust={c.trust:.2f}, provenance={sorted(c.provenance)})"
                        for c in candidates
                    )
                    self._logger.warning(
                        "Semantic merge conflict on %s→%s for '%s': %s. Chose %r from %s",
                        u,
                        v,
                        attr_key,
                        candidate_details,
                        chosen.value,
                        chosen.source or f"graph[{chosen.priority}]",
                    )

        for (u, v), provenance in edge_provenance.items():
            if provenance:
                merged.edges[u, v]["provenance"] = sorted(provenance)

        if merge_conflicts:
            merged.graph["merge_conflicts"] = merge_conflicts

        return merged

    # ------------------------------------------------------------------ #
    def _graph_depth(self, graph: nx.DiGraph | None) -> int:
        if graph is None or graph.number_of_nodes() == 0:
            return 0
        if not nx.is_directed_acyclic_graph(graph):
            return 1
        topo = list(nx.topological_generations(graph))
        return len(topo)

    # ------------------------------------------------------------------ #
    def inconsistencies(
            self,
            graphs: Iterable[Any]) -> "SymbolicInconsistency":
        episode_graphs: list[tuple[str, nx.DiGraph]] = []
        merge_inputs: list[tuple[nx.DiGraph, Mapping[str, Any]]] = []

        for idx, entry in enumerate(graphs):
            graph: nx.DiGraph | None
            metadata: Mapping[str, Any] | None

            if isinstance(
                    entry,
                    tuple) and len(entry) == 2 and isinstance(
                    entry[0],
                    nx.DiGraph):
                graph = entry[0]
                metadata = entry[1] if isinstance(entry[1], Mapping) else None
            else:
                graph = entry if isinstance(entry, nx.DiGraph) else None
                metadata = None

            if graph is None or graph.number_of_nodes() == 0:
                continue

            episode_id = (str(metadata.get("episode_id",
                                           f"graph_{idx}")) if metadata else f"graph_{idx}")
            episode_graphs.append((episode_id, graph))

            merge_meta: dict[str, Any] = {"episode_id": episode_id}
            if metadata:
                merge_meta.update(dict(metadata))
            merge_inputs.append((graph, merge_meta))

        if not episode_graphs:
            return SymbolicInconsistency.empty()

        self.merge_graphs(merge_inputs)

        node_types, attribute_values, edge_relations, relation_targets = _index_episode_graphs(
            episode_graphs)

        semantic_cycles = _detect_semantic_cycles(episode_graphs)
        attribute_conflicts = _detect_attribute_conflicts(node_types)
        equivalence_conflicts = _detect_equivalence_conflicts(
            episode_graphs, node_types)
        if equivalence_conflicts:
            equivalence_nodes = {
                node for conflict in equivalence_conflicts for node in conflict.get(
                    "component", [])}
            attribute_conflicts = [
                conflict
                for conflict in attribute_conflicts
                if conflict.get("node") not in equivalence_nodes
            ]
        mutually_exclusive = _detect_mutually_exclusive_attributes(
            attribute_values)
        incompatible_relations = _detect_incompatible_relations(edge_relations)
        domain_conflicts = _detect_domain_conflicts(edge_relations, node_types)
        cardinality_conflicts = _detect_relation_cardinality_conflicts(
            relation_targets)

        episode_violations: dict[str, list[dict[str, Any]]] = {
            episode_id: [] for episode_id, _ in episode_graphs
        }

        for relation, entries in semantic_cycles.items():
            for entry in entries:
                ep = entry.get("episode")
                if ep in episode_violations:
                    episode_violations[ep].append(
                        {
                            "type": "semantic_cycle",
                            "relation": relation,
                            "nodes": entry.get("nodes", []),
                        }
                    )

        for conflict in attribute_conflicts:
            for ep, types in conflict.get("episodes", {}).items():
                if types and ep in episode_violations:
                    episode_violations[ep].append(
                        {
                            "type": "attribute_conflict",
                            "node": conflict["node"],
                            "types": conflict["types"],
                            "observed_types": types,
                        }
                    )

        for conflict in equivalence_conflicts:
            for ep in conflict.get("episodes", []):
                if ep in episode_violations:
                    episode_violations[ep].append(
                        {
                            "type": "equivalence_conflict",
                            "nodes": conflict["component"],
                            "types": conflict["types"],
                        }
                    )

        for attribute, entries in mutually_exclusive.items():
            for entry in entries:
                for ep in entry.get("episodes", []):
                    if ep in episode_violations:
                        episode_violations[ep].append(
                            {
                                "type": "mutually_exclusive_attribute",
                                "attribute": attribute,
                                "node": entry["node"],
                                "values": entry["values"],
                            }
                        )

        for conflict in incompatible_relations:
            for ep in conflict.get("episodes", []):
                if ep in episode_violations:
                    episode_violations[ep].append(
                        {
                            "type": "incompatible_relation",
                            "relation": conflict["relation"],
                            "source": conflict["source"],
                            "target": conflict["target"],
                        }
                    )

        for conflict in domain_conflicts:
            for ep in conflict.get("episodes", []):
                if ep in episode_violations:
                    episode_violations[ep].append(
                        {
                            "type": "domain_conflict",
                            "rule": conflict["rule"],
                            "source": conflict["source"],
                            "target": conflict["target"],
                            "detail": conflict["detail"],
                        }
                    )

        for conflict in cardinality_conflicts:
            for ep in conflict.get("episodes", []):
                if ep in episode_violations:
                    episode_violations[ep].append(
                        {
                            "type": "relation_cardinality",
                            "rule": conflict["rule"],
                            "node": conflict["node"],
                            "relation": conflict["relation"],
                            "targets": conflict["targets"],
                            "limit": conflict["limit"],
                            "count": conflict["count"],
                        }
                    )

        episode_violations = {
            episode: details for episode,
            details in episode_violations.items() if details}

        return SymbolicInconsistency(
            semantic_cycles=semantic_cycles,
            attribute_conflicts=attribute_conflicts,
            equivalence_conflicts=equivalence_conflicts,
            mutually_exclusive_attributes=mutually_exclusive,
            incompatible_relations=incompatible_relations,
            domain_conflicts=domain_conflicts,
            cardinality_conflicts=cardinality_conflicts,
            episode_violations=episode_violations,
        )


def aggregate_symbolic_scores(
        scores: Mapping[str, SymbolicScore], temperature: float = 1.0) -> dict[str, float]:
    if not scores:
        return {}
    weights = np.array([score.combined()
                       for score in scores.values()], dtype=float)
    if weights.size == 0:
        return {}
    if temperature <= 0:
        temperature = 1.0
    scaled = weights / temperature
    scaled -= np.max(scaled)
    exp = np.exp(scaled)
    norm = _normalise(exp)
    return {episode_id: float(weight)
            for episode_id, weight in zip(scores.keys(), norm)}


def _attributes_match(
        attrs_a: Mapping[str, Any], attrs_b: Mapping[str, Any]) -> bool:
    if not attrs_a:
        return True
    for key, value in attrs_a.items():
        if attrs_b.get(key) != value:
            return False
    return True


def _edge_relations(attrs: Mapping[str, Any] | None) -> set[str]:
    if not attrs:
        return set()
    relations: set[str] = set()
    for key in ("relation", "type"):
        relations.update(_normalise_attribute_values(attrs.get(key)))
    return relations


@dataclass(slots=True)
class SymbolicInconsistency:
    """Summary of symbolic contradictions detected in a collection of graphs."""

    semantic_cycles: dict[str, list[dict[str, Any]]]
    attribute_conflicts: list[dict[str, Any]]
    equivalence_conflicts: list[dict[str, Any]]
    mutually_exclusive_attributes: dict[str, list[dict[str, Any]]]
    incompatible_relations: list[dict[str, Any]]
    domain_conflicts: list[dict[str, Any]]
    cardinality_conflicts: list[dict[str, Any]]
    episode_violations: dict[str, list[dict[str, Any]]]

    @classmethod
    def empty(cls) -> "SymbolicInconsistency":
        return cls(
            semantic_cycles={},
            attribute_conflicts=[],
            equivalence_conflicts=[],
            mutually_exclusive_attributes={},
            incompatible_relations=[],
            domain_conflicts=[],
            cardinality_conflicts=[],
            episode_violations={},
        )

    def total_semantic_cycles(self) -> int:
        return sum(len(entries) for entries in self.semantic_cycles.values())

    def total_contradictions(self) -> int:
        exclusive_conflicts = sum(
            len(entries) for entries in self.mutually_exclusive_attributes.values())
        return (
            self.total_semantic_cycles()
            + len(self.attribute_conflicts)
            + len(self.equivalence_conflicts)
            + exclusive_conflicts
            + len(self.incompatible_relations)
            + len(self.domain_conflicts)
            + len(self.cardinality_conflicts)
        )

    def to_dict(self) -> dict[str, Any]:
        data: dict[str, Any] = {
            "total_contradictions": self.total_contradictions(),
            "semantic_cycle_count": self.total_semantic_cycles(),
            "attribute_conflicts": len(self.attribute_conflicts),
            "equivalence_conflicts": len(self.equivalence_conflicts),
            "mutually_exclusive_attribute_conflicts": sum(
                len(entries) for entries in self.mutually_exclusive_attributes.values()
            ),
            "incompatible_relation_conflicts": len(self.incompatible_relations),
            "domain_specific_conflicts": len(self.domain_conflicts),
            "relationship_cardinality_conflicts": len(self.cardinality_conflicts),
        }
        for relation in _ACYCLIC_RELATIONS:
            data[f"{relation}_cycles"] = len(
                self.semantic_cycles.get(relation, []))
        for relation, entries in self.semantic_cycles.items():
            if relation in _ACYCLIC_RELATIONS:
                continue
            data[f"{relation}_cycles"] = len(entries)
        data["violations"] = {
            "semantic_cycles": self.semantic_cycles,
            "attribute_conflicts": self.attribute_conflicts,
            "equivalence_conflicts": self.equivalence_conflicts,
            "mutually_exclusive_attributes": self.mutually_exclusive_attributes,
            "incompatible_relations": self.incompatible_relations,
            "domain_conflicts": self.domain_conflicts,
            "relation_cardinality_conflicts": self.cardinality_conflicts,
            "by_episode": {
                episode: details
                for episode, details in self.episode_violations.items()
                if details
            },
        }
        return data


def _index_episode_graphs(
    graphs: Sequence[tuple[str, nx.DiGraph]]
) -> tuple[
    dict[str, dict[str, set[str]]],
    dict[str, dict[str, dict[str, set[str]]]],
    dict[tuple[str, str], dict[str, set[str]]],
    dict[str, dict[str, dict[str, set[str]]]],
]:
    node_types_index: defaultdict[str, defaultdict[str, set[str]]] = defaultdict(
        lambda: defaultdict(set))
    attribute_values_index: dict[str, defaultdict[str, defaultdict[str, set[str]]]] = {
        attribute: defaultdict(lambda: defaultdict(set))
        for attribute in _MUTUALLY_EXCLUSIVE_ATTRIBUTE_RULES
    }
    edge_relations_index: defaultdict[tuple[str, str], defaultdict[str, set[str]]] = defaultdict(
        lambda: defaultdict(set))
    relation_targets_index: defaultdict[
        str, defaultdict[str, defaultdict[str, set[str]]]
    ] = defaultdict(lambda: defaultdict(lambda: defaultdict(set)))

    for episode_id, graph in graphs:
        for node, attrs in graph.nodes(data=True):
            node_key = str(node)
            types = _normalise_types(attrs.get("type"))
            if types:
                node_types_index[node_key][episode_id].update(types)

            for attribute in _MUTUALLY_EXCLUSIVE_ATTRIBUTE_RULES:
                values = _normalise_attribute_values(attrs.get(attribute))
                if values:
                    attribute_values_index[attribute][node_key][episode_id].update(
                        values)

        for u, v, attrs in graph.edges(data=True):
            u_key, v_key = str(u), str(v)
            relations = _edge_relations(attrs)
            if not relations:
                continue
            edge_relations_index[(u_key, v_key)][episode_id].update(relations)
            for relation in relations:
                relation_targets_index[relation][episode_id][u_key].add(v_key)

    node_types = {
        node: {episode: set(values) for episode, values in episode_map.items()}
        for node, episode_map in node_types_index.items()
    }
    attribute_values = {
        attribute: {
            node: {episode: set(values) for episode, values in episode_map.items()}
            for node, episode_map in node_map.items()
        }
        for attribute, node_map in attribute_values_index.items()
    }
    edge_relations = {
        key: {episode: set(values) for episode, values in episode_map.items()}
        for key, episode_map in edge_relations_index.items()
    }
    relation_targets = {
        relation: {
            episode: {source: set(targets) for source, targets in source_map.items()}
            for episode, source_map in episode_map.items()
        }
        for relation, episode_map in relation_targets_index.items()
    }

    return node_types, attribute_values, edge_relations, relation_targets


def _detect_semantic_cycles(
    graphs: Sequence[tuple[str, nx.DiGraph]]
) -> dict[str, list[dict[str, Any]]]:
    cycles: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
    for episode_id, graph in graphs:
        for relation in _ACYCLIC_RELATIONS:
            subgraph = nx.DiGraph()
            for u, v, attrs in graph.edges(data=True):
                if _relation_matches(attrs, relation):
                    subgraph.add_edge(str(u), str(v))
            if subgraph.number_of_edges() == 0:
                continue
            for nodes in nx.simple_cycles(subgraph):
                cycles[relation].append({"episode": episode_id, "nodes": [
                    str(node) for node in nodes]})
    return {relation: entries for relation,
            entries in cycles.items() if entries}


def _detect_attribute_conflicts(
    node_types: Mapping[str, Mapping[str, set[str]]]
) -> list[dict[str, Any]]:
    conflicts: list[dict[str, Any]] = []
    for node, per_episode in node_types.items():
        union: set[str] = set()
        for types in per_episode.values():
            union.update(types)
        if len(union) <= 1:
            continue
        conflicts.append({"node": node, "types": sorted(union), "episodes": {
            episode: sorted(types) for episode, types in per_episode.items()}, })
    return conflicts


def _detect_equivalence_conflicts(
    graphs: Sequence[tuple[str, nx.DiGraph]],
    node_types: Mapping[str, Mapping[str, set[str]]],
) -> list[dict[str, Any]]:
    same_as_graph = nx.Graph()
    for _, graph in graphs:
        for node in graph.nodes:
            same_as_graph.add_node(str(node))
        for u, v, attrs in graph.edges(data=True):
            if any(_relation_matches(attrs, relation)
                   for relation in _EQUIVALENCE_RELATIONS):
                same_as_graph.add_edge(str(u), str(v))

    conflicts: list[dict[str, Any]] = []
    for component in nx.connected_components(same_as_graph):
        if len(component) <= 1:
            continue
        component_types: set[str] = set()
        episodes_involved: set[str] = set()
        for node in component:
            for episode, types in node_types.get(node, {}).items():
                if types:
                    component_types.update(types)
                    episodes_involved.add(episode)
        if len(component_types) > 1 and episodes_involved:
            conflicts.append(
                {
                    "component": sorted(str(node) for node in component),
                    "types": sorted(component_types),
                    "episodes": sorted(episodes_involved),
                }
            )
    return conflicts


def _detect_mutually_exclusive_attributes(
    attribute_values: Mapping[str, Mapping[str, Mapping[str, set[str]]]]
) -> dict[str, list[dict[str, Any]]]:
    conflicts: dict[str, list[dict[str, Any]]] = {}
    for attribute, node_map in attribute_values.items():
        exclusive_sets = _MUTUALLY_EXCLUSIVE_ATTRIBUTE_RULES.get(attribute, [])
        if not exclusive_sets:
            continue
        attribute_conflicts: list[dict[str, Any]] = []
        for node, per_episode in node_map.items():
            if not per_episode:
                continue
            value_to_episode: defaultdict[str, set[str]] = defaultdict(set)
            for episode, values in per_episode.items():
                for value in values:
                    value_to_episode[value].add(episode)
            for exclusive in exclusive_sets:
                if exclusive.issubset(value_to_episode.keys()):
                    episodes_involved = sorted(
                        {episode for value in exclusive for episode in value_to_episode[value]}
                    )
                    attribute_conflicts.append(
                        {
                            "node": node,
                            "values": sorted(exclusive),
                            "episodes": episodes_involved,
                        }
                    )
        if attribute_conflicts:
            conflicts[attribute] = attribute_conflicts
    return conflicts


def _detect_incompatible_relations(
    edge_relations: Mapping[tuple[str, str], Mapping[str, set[str]]]
) -> list[dict[str, Any]]:
    conflicts: list[dict[str, Any]] = []
    seen: set[tuple[str, str, str]] = set()
    for (u, v), per_episode in edge_relations.items():
        reverse = edge_relations.get((v, u), {})
        for relation in _ANTI_SYMMETRIC_RELATIONS:
            forward_eps = {
                episode for episode,
                relations in per_episode.items() if relation in relations}
            if not forward_eps:
                continue
            reverse_eps = {
                episode for episode,
                relations in reverse.items() if relation in relations}
            if not reverse_eps:
                continue
            ordered = tuple(sorted((u, v)))
            key = (relation, ordered[0], ordered[1])
            if key in seen:
                continue
            seen.add(key)
            conflicts.append(
                {
                    "relation": relation,
                    "source": ordered[0],
                    "target": ordered[1],
                    "episodes": sorted(forward_eps | reverse_eps),
                }
            )
    return conflicts


def _detect_domain_conflicts(
    edge_relations: Mapping[tuple[str, str], Mapping[str, set[str]]],
    node_types: Mapping[str, Mapping[str, set[str]]],
) -> list[dict[str, Any]]:
    conflicts: list[dict[str, Any]] = []
    seen_type: set[tuple[str, str, str, str, str]] = set()
    seen_rel: set[tuple[str, str, str, str]] = set()

    for (u, v), per_episode in edge_relations.items():
        for episode, relations in per_episode.items():
            for rule in _DOMAIN_TYPE_CONSTRAINTS:
                relation_name = rule.get("relation")
                if relation_name not in relations:
                    continue
                rule_name = str(rule.get("name", relation_name))
                required_sources = set(rule.get("source_types", set()))
                required_targets = set(rule.get("target_types", set()))

                if required_sources:
                    source_types = node_types.get(u, {}).get(episode, set())
                    if source_types and not source_types & required_sources:
                        type_key = (rule_name, u, v, "source", episode)
                        if type_key not in seen_type:
                            conflicts.append(
                                {
                                    "rule": rule_name,
                                    "source": u,
                                    "target": v,
                                    "detail": "source_type_mismatch",
                                    "episodes": [episode],
                                }
                            )
                            seen_type.add(type_key)

                if required_targets:
                    target_types = node_types.get(v, {}).get(episode, set())
                    if target_types and not target_types & required_targets:
                        type_key = (rule_name, u, v, "target", episode)
                        if type_key not in seen_type:
                            conflicts.append(
                                {
                                    "rule": rule_name,
                                    "source": u,
                                    "target": v,
                                    "detail": "target_type_mismatch",
                                    "episodes": [episode],
                                }
                            )
                            seen_type.add(type_key)

        for primary, counterpart, rule_name in _DOMAIN_RELATION_CONFLICTS:
            primary_eps = {
                episode for episode,
                relations in per_episode.items() if primary in relations}
            if not primary_eps:
                continue
            same_direction = {
                episode for episode,
                relations in per_episode.items() if counterpart in relations}
            if same_direction:
                rel_key = (rule_name, u, v, "same")
                if rel_key not in seen_rel:
                    conflicts.append(
                        {
                            "rule": rule_name,
                            "source": u,
                            "target": v,
                            "detail": "same_edge_conflict",
                            "episodes": sorted(primary_eps | same_direction),
                        }
                    )
                    seen_rel.add(rel_key)
            reverse_eps = {
                episode
                for episode, relations in edge_relations.get((v, u), {}).items()
                if counterpart in relations
            }
            if reverse_eps:
                rel_key = (rule_name, u, v, "reciprocal")
                if rel_key not in seen_rel:
                    conflicts.append(
                        {
                            "rule": rule_name,
                            "source": u,
                            "target": v,
                            "detail": "reciprocal_conflict",
                            "episodes": sorted(primary_eps | reverse_eps),
                        }
                    )
                    seen_rel.add(rel_key)

    return conflicts


def _detect_relation_cardinality_conflicts(
    relation_targets: Mapping[str, Mapping[str, Mapping[str, set[str]]]]
) -> list[dict[str, Any]]:
    conflicts: list[dict[str, Any]] = []
    for rule in _RELATION_CARDINALITY_CONSTRAINTS:
        relation = str(rule.get("relation"))
        if relation not in relation_targets:
            continue
        limit = int(rule.get("max_targets", 1))
        if limit < 1:
            continue
        rule_name = str(rule.get("name", f"{relation}_cardinality"))
        for episode, sources in relation_targets[relation].items():
            for source, targets in sources.items():
                if len(targets) > limit:
                    conflicts.append(
                        {
                            "rule": rule_name,
                            "node": source,
                            "relation": relation,
                            "targets": sorted(targets),
                            "limit": limit,
                            "count": len(targets),
                            "episodes": [episode],
                        }
                    )
    return conflicts


def _normalise_attribute_values(value: Any) -> set[str]:
    if value is None:
        return set()
    if isinstance(value, bool):
        return {str(value).lower()}
    if isinstance(value, (int, float)):
        return {str(value)}
    return _normalise_types(value)


def _relation_matches(attrs: Mapping[str, Any], relation: str) -> bool:
    value = attrs.get("relation")
    if value is None:
        value = attrs.get("type")
    if value is None:
        return False
    if isinstance(value, str):
        return value.lower() == relation
    if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
        for item in value:
            if isinstance(item, str) and item.lower() == relation:
                return True
    return False


def _normalise_types(value: Any) -> set[str]:
    if value is None:
        return set()
    if isinstance(value, str):
        return {value.lower()}
    if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
        return {str(item).lower() for item in value if item is not None}
    return {str(value).lower()}
