"""Tests for the planning phase (split strategy generation)."""

from __future__ import annotations

import json
from pathlib import Path

from pathlib import Path

import pytest

from cleave.orchestrator.errors import PlanningFailedError
from cleave.orchestrator.planner import parse_plan_response, validate_scopes


def _two_children(**overrides):
    """Helper to build a valid 2-child plan with optional overrides on child 0."""
    child_a = {"label": "task-a", "description": "Do task A", "scope": ["src/a/**"]}
    child_a.update(overrides)
    child_b = {"label": "task-b", "description": "Do task B", "scope": ["src/b/**"]}
    return {"children": [child_a, child_b], "rationale": "Split by module"}


class TestParsePlanResponse:
    def test_valid_json(self) -> None:
        response = json.dumps({
            "children": [
                {"label": "auth-core", "description": "Implement auth", "scope": ["src/auth/**"]},
                {"label": "auth-tests", "description": "Write tests", "scope": ["tests/**"]},
            ],
            "rationale": "Split by feature and tests",
        })
        result = parse_plan_response(response)
        assert len(result["children"]) == 2
        assert result["children"][0]["label"] == "auth-core"

    def test_strips_markdown_fences(self) -> None:
        inner = json.dumps(_two_children())
        response = f"```json\n{inner}\n```"
        result = parse_plan_response(response)
        assert len(result["children"]) == 2

    def test_extracts_json_from_text(self) -> None:
        payload = json.dumps(_two_children())
        response = f"Here is my plan:\n{payload}\nThat should work."
        result = parse_plan_response(response)
        assert result["children"][0]["label"] == "task-a"

    def test_normalizes_labels(self) -> None:
        response = json.dumps(_two_children(label="Auth Core Module"))
        result = parse_plan_response(response)
        assert result["children"][0]["label"] == "auth-core-module"

    def test_adds_default_scope(self) -> None:
        child_a = {"label": "fix-a", "description": "Fix it"}
        child_b = {"label": "fix-b", "description": "Fix that"}
        response = json.dumps({"children": [child_a, child_b]})
        result = parse_plan_response(response)
        assert result["children"][0]["scope"] == []
        assert result["children"][1]["scope"] == []

    def test_truncates_long_labels(self) -> None:
        response = json.dumps(_two_children(label="a" * 100))
        result = parse_plan_response(response)
        assert len(result["children"][0]["label"]) <= 40

    def test_rejects_missing_children(self) -> None:
        response = json.dumps({"rationale": "No children"})
        with pytest.raises(PlanningFailedError, match="no 'children'"):
            parse_plan_response(response)

    def test_rejects_non_dict_response(self) -> None:
        with pytest.raises(PlanningFailedError, match="not a JSON object"):
            parse_plan_response("[1, 2, 3]")

    def test_rejects_missing_label(self) -> None:
        response = json.dumps({
            "children": [
                {"description": "No label"},
                {"label": "valid", "description": "Has label"},
            ],
        })
        with pytest.raises(PlanningFailedError, match="missing 'label'"):
            parse_plan_response(response)

    def test_rejects_missing_description(self) -> None:
        response = json.dumps({
            "children": [
                {"label": "fix-a", "description": "Has desc"},
                {"label": "fix-b"},
            ],
        })
        with pytest.raises(PlanningFailedError, match="missing 'description'"):
            parse_plan_response(response)

    def test_rejects_garbage_input(self) -> None:
        with pytest.raises(PlanningFailedError, match="No JSON"):
            parse_plan_response("This is not JSON at all")

    def test_rejects_empty_children(self) -> None:
        response = json.dumps({"children": []})
        with pytest.raises(PlanningFailedError, match="no 'children'"):
            parse_plan_response(response)

    def test_rejects_single_child(self) -> None:
        response = json.dumps({
            "children": [{"label": "solo", "description": "Only one"}],
        })
        with pytest.raises(PlanningFailedError, match="minimum is 2"):
            parse_plan_response(response)

    def test_truncates_to_four(self) -> None:
        children = [
            {"label": f"task-{i}", "description": f"Task {i}"}
            for i in range(7)
        ]
        response = json.dumps({"children": children})
        result = parse_plan_response(response)
        assert len(result["children"]) == 4


class TestDependsOn:
    def test_depends_on_default_empty(self) -> None:
        response = json.dumps(_two_children())
        result = parse_plan_response(response)
        assert result["children"][0]["depends_on"] == []
        assert result["children"][1]["depends_on"] == []

    def test_depends_on_preserved(self) -> None:
        plan = _two_children()
        plan["children"][1]["depends_on"] = ["task-a"]
        response = json.dumps(plan)
        result = parse_plan_response(response)
        assert result["children"][1]["depends_on"] == ["task-a"]

    def test_unknown_ref_pruned(self) -> None:
        plan = _two_children()
        plan["children"][0]["depends_on"] = ["nonexistent-child"]
        response = json.dumps(plan)
        result = parse_plan_response(response)
        assert result["children"][0]["depends_on"] == []

    def test_self_dep_removed(self) -> None:
        plan = _two_children()
        plan["children"][0]["depends_on"] = ["task-a"]
        response = json.dumps(plan)
        result = parse_plan_response(response)
        assert result["children"][0]["depends_on"] == []

    def test_cycle_detection_clears_deps(self) -> None:
        plan = _two_children()
        plan["children"][0]["depends_on"] = ["task-b"]
        plan["children"][1]["depends_on"] = ["task-a"]
        response = json.dumps(plan)
        result = parse_plan_response(response)
        # Both are in a cycle, so at least one should have deps cleared
        a_deps = result["children"][0]["depends_on"]
        b_deps = result["children"][1]["depends_on"]
        # After cycle breaking, the graph should be acyclic
        # (both nodes in cycle have deps cleared)
        assert a_deps == [] and b_deps == []


class TestValidateScopes:
    def test_existing_scope_no_warning(self, tmp_path: Path) -> None:
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "foo.py").touch()
        plan = {
            "children": [
                {"label": "a", "scope": ["src/*"]},
                {"label": "b", "scope": ["src/foo.py"]},
            ]
        }
        warnings = validate_scopes(plan, tmp_path)
        assert len(warnings) == 0

    def test_missing_scope_warns(self, tmp_path: Path) -> None:
        plan = {
            "children": [
                {"label": "a", "scope": ["nonexistent/**"]},
                {"label": "b", "scope": []},
            ]
        }
        warnings = validate_scopes(plan, tmp_path)
        assert len(warnings) == 1
        assert "nonexistent/**" in warnings[0]
        assert "no existing files" in warnings[0]

    def test_overlapping_scope_warns(self, tmp_path: Path) -> None:
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "shared.py").touch()
        plan = {
            "children": [
                {"label": "a", "scope": ["src/**"]},
                {"label": "b", "scope": ["src/**"]},
            ]
        }
        warnings = validate_scopes(plan, tmp_path)
        overlap_warnings = [w for w in warnings if "multiple children" in w]
        assert len(overlap_warnings) == 1
        assert "a" in overlap_warnings[0] and "b" in overlap_warnings[0]

    def test_empty_scope_no_warning(self, tmp_path: Path) -> None:
        plan = {
            "children": [
                {"label": "a", "scope": []},
                {"label": "b", "scope": []},
            ]
        }
        warnings = validate_scopes(plan, tmp_path)
        assert len(warnings) == 0
