# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT
"""Unit tests for the Plan DAG Generator (OMN-2502).

Test markers:
    @pytest.mark.unit  — all tests here

Coverage:
- R1: Generate Plan DAG from typed Intent object
  - Accepts typed Intent (via ModelPlanDagRequest)
  - Emits ModelPlanDag with nodes and edges
  - DAG is acyclic (enforced)
  - Each node contains work unit description, type, estimated scope
  - Plan DAG is serializable (JSON/YAML)
- R2: Dependency resolution
  - Topological sort available
  - Circular dependency detection raises ValueError
  - Orphaned nodes flagged
"""

from __future__ import annotations

import json
import uuid

import pytest
from pydantic import ValidationError

from omniclaude.nodes.node_plan_dag_generator.enums.enum_work_unit_type import (
    EnumWorkUnitType,
)
from omniclaude.nodes.node_plan_dag_generator.handler_plan_dag_default import (
    HandlerPlanDagDefault,
    _build_dag_from_template,
    _find_orphaned_nodes,
)
from omniclaude.nodes.node_plan_dag_generator.models.model_dag_edge import ModelDagEdge
from omniclaude.nodes.node_plan_dag_generator.models.model_plan_dag import ModelPlanDag
from omniclaude.nodes.node_plan_dag_generator.models.model_plan_dag_request import (
    ModelPlanDagRequest,
)
from omniclaude.nodes.node_plan_dag_generator.models.model_work_unit import (
    ModelWorkUnit,
)

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


def _request(intent_type: str, **kwargs: object) -> ModelPlanDagRequest:
    defaults: dict[str, object] = {
        "intent_id": str(uuid.uuid4()),
        "intent_type": intent_type,
        "intent_summary": f"Test intent: {intent_type}",
        "correlation_id": uuid.uuid4(),
    }
    defaults.update(kwargs)
    return ModelPlanDagRequest(**defaults)  # type: ignore[arg-type]


def _handler() -> HandlerPlanDagDefault:
    return HandlerPlanDagDefault()


def _work_unit(unit_id: str = "", title: str = "Task") -> ModelWorkUnit:
    return ModelWorkUnit(
        unit_id=unit_id or str(uuid.uuid4()),
        title=title,
        unit_type=EnumWorkUnitType.GENERIC,
    )


# ---------------------------------------------------------------------------
# R1: ModelWorkUnit validation
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestModelWorkUnit:
    """ModelWorkUnit construction and validation."""

    def test_is_frozen(self) -> None:
        unit = _work_unit()
        with pytest.raises(Exception):
            unit.title = "changed"  # type: ignore[misc]

    def test_default_scope_is_m(self) -> None:
        unit = _work_unit()
        assert unit.estimated_scope == "M"

    def test_valid_scope_values(self) -> None:
        for scope in ("XS", "S", "M", "L", "XL"):
            unit = ModelWorkUnit(
                unit_id="u1",
                title="T",
                unit_type=EnumWorkUnitType.GENERIC,
                estimated_scope=scope,
            )
            assert unit.estimated_scope == scope

    def test_invalid_scope_rejected(self) -> None:
        with pytest.raises(ValidationError):
            ModelWorkUnit(
                unit_id="u1",
                title="T",
                unit_type=EnumWorkUnitType.GENERIC,
                estimated_scope="HUGE",
            )


# ---------------------------------------------------------------------------
# R1: ModelDagEdge validation
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestModelDagEdge:
    """ModelDagEdge construction and validation."""

    def test_valid_edge(self) -> None:
        edge = ModelDagEdge(from_unit_id="a", to_unit_id="b")
        assert edge.from_unit_id == "a"
        assert edge.to_unit_id == "b"

    def test_self_loop_rejected(self) -> None:
        with pytest.raises(ValidationError, match="self-loop"):
            ModelDagEdge(from_unit_id="a", to_unit_id="a")

    def test_is_frozen(self) -> None:
        edge = ModelDagEdge(from_unit_id="a", to_unit_id="b")
        with pytest.raises(Exception):
            edge.from_unit_id = "c"  # type: ignore[misc]


# ---------------------------------------------------------------------------
# R1: ModelPlanDag validation
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestModelPlanDag:
    """ModelPlanDag construction and validation."""

    def test_empty_dag_is_valid(self) -> None:
        dag = ModelPlanDag(
            dag_id="d1",
            intent_id="i1",
        )
        assert dag.nodes == ()
        assert dag.edges == ()

    def test_edge_to_unknown_node_rejected(self) -> None:
        unit = _work_unit("u1")
        with pytest.raises(ValidationError, match="unknown from_unit_id"):
            ModelPlanDag(
                dag_id="d1",
                intent_id="i1",
                nodes=(unit,),
                edges=(ModelDagEdge(from_unit_id="UNKNOWN", to_unit_id="u1"),),
            )

    def test_is_frozen(self) -> None:
        dag = ModelPlanDag(dag_id="d1", intent_id="i1")
        with pytest.raises(Exception):
            dag.dag_id = "d2"  # type: ignore[misc]

    def test_is_json_serializable(self) -> None:
        u1 = _work_unit("u1", "Task A")
        u2 = _work_unit("u2", "Task B")
        edge = ModelDagEdge(from_unit_id="u1", to_unit_id="u2")
        dag = ModelPlanDag(
            dag_id="d1",
            intent_id="i1",
            nodes=(u1, u2),
            edges=(edge,),
        )
        as_json = dag.model_dump_json()
        parsed = json.loads(as_json)
        assert len(parsed["nodes"]) == 2
        assert len(parsed["edges"]) == 1

    def test_roots_identified(self) -> None:
        u1 = _work_unit("u1", "Root")
        u2 = _work_unit("u2", "Child")
        edge = ModelDagEdge(from_unit_id="u1", to_unit_id="u2")
        dag = ModelPlanDag(dag_id="d1", intent_id="i1", nodes=(u1, u2), edges=(edge,))
        roots = dag.roots()
        assert len(roots) == 1
        assert roots[0].unit_id == "u1"

    def test_leaves_identified(self) -> None:
        u1 = _work_unit("u1", "Root")
        u2 = _work_unit("u2", "Leaf")
        edge = ModelDagEdge(from_unit_id="u1", to_unit_id="u2")
        dag = ModelPlanDag(dag_id="d1", intent_id="i1", nodes=(u1, u2), edges=(edge,))
        leaves = dag.leaves()
        assert len(leaves) == 1
        assert leaves[0].unit_id == "u2"


# ---------------------------------------------------------------------------
# R2: Topological sort and cycle detection
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestTopologicalSort:
    """Plan DAG topological sort and cycle detection."""

    def test_single_node_sort(self) -> None:
        u = _work_unit("u1")
        dag = ModelPlanDag(dag_id="d1", intent_id="i1", nodes=(u,))
        result = dag.topological_sort()
        assert len(result) == 1
        assert result[0].unit_id == "u1"

    def test_linear_chain_sort(self) -> None:
        u1 = _work_unit("u1", "A")
        u2 = _work_unit("u2", "B")
        u3 = _work_unit("u3", "C")
        edges = (
            ModelDagEdge(from_unit_id="u1", to_unit_id="u2"),
            ModelDagEdge(from_unit_id="u2", to_unit_id="u3"),
        )
        dag = ModelPlanDag(dag_id="d1", intent_id="i1", nodes=(u1, u2, u3), edges=edges)
        result = dag.topological_sort()
        ids = [n.unit_id for n in result]
        assert ids.index("u1") < ids.index("u2") < ids.index("u3")

    def test_diamond_dependency_sort(self) -> None:
        # A → B, A → C, B → D, C → D
        u = {k: _work_unit(k) for k in ("A", "B", "C", "D")}
        edges = (
            ModelDagEdge(from_unit_id="A", to_unit_id="B"),
            ModelDagEdge(from_unit_id="A", to_unit_id="C"),
            ModelDagEdge(from_unit_id="B", to_unit_id="D"),
            ModelDagEdge(from_unit_id="C", to_unit_id="D"),
        )
        dag = ModelPlanDag(
            dag_id="d1",
            intent_id="i1",
            nodes=tuple(u.values()),
            edges=edges,
        )
        result = dag.topological_sort()
        ids = [n.unit_id for n in result]
        assert ids.index("A") < ids.index("B")
        assert ids.index("A") < ids.index("C")
        assert ids.index("B") < ids.index("D")
        assert ids.index("C") < ids.index("D")

    def test_cycle_raises_value_error(self) -> None:
        # Build a DAG with a back-edge (u3 → u1) to create a cycle.
        # ModelPlanDag validates edge references (all unit_ids exist) but does
        # NOT reject cycles at construction time — that is enforced by
        # topological_sort() which uses Kahn's algorithm.
        u1 = _work_unit("u1")
        u2 = _work_unit("u2")
        u3 = _work_unit("u3")
        e1 = ModelDagEdge(from_unit_id="u1", to_unit_id="u2")
        e2 = ModelDagEdge(from_unit_id="u2", to_unit_id="u3")
        e3 = ModelDagEdge(from_unit_id="u3", to_unit_id="u1")  # back-edge: cycle
        dag = ModelPlanDag(
            dag_id="d1",
            intent_id="i1",
            nodes=(u1, u2, u3),
            edges=(e1, e2, e3),
        )
        with pytest.raises(ValueError, match="Cycle detected"):
            dag.topological_sort()

    def test_cycle_in_template_build_raises(self) -> None:
        """_build_dag_from_template raises if cycle is introduced."""
        # Artificially create cyclic dep specs
        with pytest.raises(ValueError, match="Cycle detected"):
            _build_dag_from_template(
                dag_id="d1",
                intent_id="i1",
                intent_summary="test",
                unit_specs=[
                    ("a", "Task A", EnumWorkUnitType.GENERIC, "S"),
                    ("b", "Task B", EnumWorkUnitType.GENERIC, "S"),
                ],
                dep_specs=[("a", "b"), ("b", "a")],  # cycle!
            )


# ---------------------------------------------------------------------------
# R2: Orphaned node detection
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestOrphanedNodes:
    """Orphaned node detection in Plan DAG."""

    def test_no_orphaned_in_linear_chain(self) -> None:
        units = tuple(_work_unit(f"u{i}") for i in range(3))
        edges = (
            ModelDagEdge(from_unit_id="u0", to_unit_id="u1"),
            ModelDagEdge(from_unit_id="u1", to_unit_id="u2"),
        )
        orphaned = _find_orphaned_nodes(units, edges)
        assert orphaned == frozenset()

    def test_isolated_node_not_orphaned(self) -> None:
        # A node with no edges is a root — not orphaned
        unit = _work_unit("solo")
        orphaned = _find_orphaned_nodes((unit,), ())
        assert orphaned == frozenset()

    def test_node_only_reachable_via_cycle_is_orphaned(self) -> None:
        # Build: root1 → a; b ↔ c (cycle disconnected from root1)
        # b and c have incoming edges only from each other — they form a cycle
        # unreachable from any root. _find_orphaned_nodes detects them as orphaned
        # because they have no path from a true root (a node with no incoming edges
        # from outside the cycle).
        units = tuple(_work_unit(uid) for uid in ("root1", "a", "b", "c"))
        edges = (
            ModelDagEdge(from_unit_id="root1", to_unit_id="a"),
            ModelDagEdge(from_unit_id="b", to_unit_id="c"),
            ModelDagEdge(from_unit_id="c", to_unit_id="b"),  # cycle
        )
        orphaned = _find_orphaned_nodes(units, edges)
        assert orphaned == frozenset({"b", "c"})


# ---------------------------------------------------------------------------
# R1: Handler — generates DAG for each intent type
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestHandlerPlanDagDefault:
    """HandlerPlanDagDefault produces valid DAGs for all intent types."""

    @pytest.mark.parametrize(
        "intent_type",
        [
            "FEATURE",
            "BUG_FIX",
            "REFACTOR",
            "TESTING",
            "DOCUMENTATION",
            "SECURITY",
            "REVIEW",
            "DEBUGGING",
            "INFRASTRUCTURE",
            "EPIC_DECOMPOSITION",
            "CODE",
            "GENERAL",
            "UNKNOWN",
        ],
    )
    def test_generates_dag_for_intent_type(self, intent_type: str) -> None:
        handler = _handler()
        dag = handler.generate_plan_dag(_request(intent_type))
        assert isinstance(dag, ModelPlanDag)
        assert len(dag.nodes) >= 1

    def test_dag_is_serializable(self) -> None:
        handler = _handler()
        dag = handler.generate_plan_dag(_request("FEATURE"))
        as_json = dag.model_dump_json()
        parsed = json.loads(as_json)
        assert "nodes" in parsed
        assert "edges" in parsed

    def test_each_node_has_required_fields(self) -> None:
        handler = _handler()
        dag = handler.generate_plan_dag(_request("FEATURE"))
        for node in dag.nodes:
            assert node.unit_id
            assert node.title
            assert node.unit_type in EnumWorkUnitType.__members__.values()
            assert node.estimated_scope in ("XS", "S", "M", "L", "XL")

    def test_dag_is_acyclic(self) -> None:
        handler = _handler()
        dag = handler.generate_plan_dag(_request("INFRASTRUCTURE"))
        # topological_sort raises on cycle; it should succeed here
        sorted_nodes = dag.topological_sort()
        assert len(sorted_nodes) == len(dag.nodes)

    def test_topological_order_respects_deps(self) -> None:
        handler = _handler()
        dag = handler.generate_plan_dag(_request("BUG_FIX"))
        sorted_nodes = dag.topological_sort()
        # Build position map
        positions = {n.unit_id: i for i, n in enumerate(sorted_nodes)}
        # Every edge must go from lower position to higher position
        for edge in dag.edges:
            assert positions[edge.from_unit_id] < positions[edge.to_unit_id]

    def test_intent_summary_propagated_to_node_descriptions(self) -> None:
        summary = "Fix the broken authentication module"
        handler = _handler()
        dag = handler.generate_plan_dag(_request("BUG_FIX", intent_summary=summary))
        assert any(summary in node.description for node in dag.nodes)

    def test_dag_id_and_intent_id_set(self) -> None:
        intent_id = str(uuid.uuid4())
        handler = _handler()
        dag = handler.generate_plan_dag(_request("CODE", intent_id=intent_id))
        assert dag.dag_id != ""
        assert dag.intent_id == intent_id

    def test_omnimemory_cache_miss_logged_and_falls_back(self) -> None:
        """Cache miss path falls back to template generation."""
        from omniclaude.nodes.node_plan_dag_generator.handler_plan_dag_default import (
            PatternCacheProtocol,
        )

        class _MissCache(PatternCacheProtocol):
            def get_pattern(self, pattern_id: str) -> None:
                return None

        handler = _handler()
        req = _request("FEATURE", omnimemory_pattern_id="nonexistent-pattern-id")
        dag = handler.generate_plan_dag(req, pattern_cache=_MissCache())
        assert isinstance(dag, ModelPlanDag)
        assert len(dag.nodes) >= 1
