"""Tests for alive.core.planner."""

import asyncio
import json

import pytest

from alive.core.planner import (
    ExecutionPlan,
    PlanStep,
    _extract_json,
    _fallback_plan,
    _parse_plan_response,
    display_plan,
    plan_request,
)
from alive.core.skill_registry import SkillRegistry
from alive.llm.base import LLMProvider


# ── Fixtures ─────────────────────────────────────────────────────────────────


class MockProvider(LLMProvider):
    """LLM provider that returns canned responses."""

    name = "mock"

    def __init__(self, responses: list[str] | None = None):
        self._responses = list(responses or [])
        self._call_count = 0

    async def complete(self, system_prompt: str, user_prompt: str) -> str:
        if self._call_count < len(self._responses):
            resp = self._responses[self._call_count]
        else:
            resp = self._responses[-1] if self._responses else ""
        self._call_count += 1
        return resp


MULTI_STEP_RESPONSE = json.dumps({
    "steps": [
        {
            "type": "EXISTING_SKILL",
            "skill": "web_fetch",
            "input": {"url": "https://example.com"},
            "description": "Fetch the webpage",
        },
        {
            "type": "NEW_SKILL",
            "capability": "Extract prices from HTML",
            "description": "Parse HTML for prices",
        },
        {
            "type": "LLM_ONLY",
            "prompt": "Summarize the prices",
            "description": "Summarize results",
        },
    ],
    "reasoning": "Need to fetch, parse, then summarize.",
})

SINGLE_SKILL_RESPONSE = json.dumps({
    "steps": [
        {
            "type": "EXISTING_SKILL",
            "skill": "web_fetch",
            "input": {"url": "https://example.com"},
            "description": "Fetch the URL",
        },
    ],
    "reasoning": "Direct skill match.",
})

QUESTION_RESPONSE = json.dumps({
    "steps": [
        {
            "type": "LLM_ONLY",
            "prompt": "Explain quantum computing",
            "description": "Answer the question",
        },
    ],
    "reasoning": "This is a knowledge question, no skills needed.",
})

NEW_SKILLS_RESPONSE = json.dumps({
    "steps": [
        {
            "type": "NEW_SKILL",
            "capability": "Convert CSV to JSON",
            "description": "Parse CSV data",
        },
        {
            "type": "NEW_SKILL",
            "capability": "Generate bar chart from data",
            "description": "Visualize the data",
        },
    ],
    "reasoning": "Both capabilities are new.",
})


@pytest.fixture
def registry(tmp_path):
    """Create an isolated registry for testing."""
    import alive.seeds._installer as installer

    original = installer._SEEDS_DIR
    installer._SEEDS_DIR = tmp_path / "empty_seeds"
    installer._SEEDS_DIR.mkdir()
    reg = SkillRegistry(skills_dir=tmp_path / "skills")
    installer._SEEDS_DIR = original
    return reg


# ── Data structure tests ─────────────────────────────────────────────────────


def test_plan_step_defaults():
    step = PlanStep(type="LLM_ONLY", description="test")
    assert step.skill is None
    assert step.input is None
    assert step.capability is None
    assert step.prompt is None


def test_execution_plan_creation():
    steps = [PlanStep(type="LLM_ONLY", description="answer")]
    plan = ExecutionPlan(steps=steps, reasoning="simple", original_request="hi")
    assert len(plan.steps) == 1
    assert plan.reasoning == "simple"
    assert plan.original_request == "hi"


def test_step_type_values():
    for t in ("EXISTING_SKILL", "NEW_SKILL", "LLM_ONLY"):
        step = PlanStep(type=t, description="ok")
        assert step.type == t


# ── JSON extraction tests ────────────────────────────────────────────────────


def test_extract_json_direct():
    data = _extract_json('{"key": "value"}')
    assert data == {"key": "value"}


def test_extract_json_from_code_block():
    response = 'Here is the plan:\n```json\n{"steps": []}\n```\nDone.'
    data = _extract_json(response)
    assert data == {"steps": []}


def test_extract_json_from_untagged_code_block():
    response = 'Plan:\n```\n{"steps": [{"x": 1}]}\n```'
    data = _extract_json(response)
    assert data == {"steps": [{"x": 1}]}


def test_extract_json_invalid_raises():
    with pytest.raises(ValueError, match="Could not parse JSON"):
        _extract_json("this is not json at all")


# ── Parse response tests ────────────────────────────────────────────────────


def test_parse_valid_multi_step():
    plan = _parse_plan_response(MULTI_STEP_RESPONSE, "test request")
    assert len(plan.steps) == 3
    assert plan.steps[0].type == "EXISTING_SKILL"
    assert plan.steps[0].skill == "web_fetch"
    assert plan.steps[0].input == {"url": "https://example.com"}
    assert plan.steps[1].type == "NEW_SKILL"
    assert plan.steps[1].capability == "Extract prices from HTML"
    assert plan.steps[2].type == "LLM_ONLY"
    assert plan.steps[2].prompt == "Summarize the prices"
    assert plan.reasoning == "Need to fetch, parse, then summarize."
    assert plan.original_request == "test request"


def test_parse_single_step():
    plan = _parse_plan_response(SINGLE_SKILL_RESPONSE, "fetch something")
    assert len(plan.steps) == 1
    assert plan.steps[0].type == "EXISTING_SKILL"
    assert plan.steps[0].skill == "web_fetch"


def test_parse_json_in_code_block():
    wrapped = f"```json\n{QUESTION_RESPONSE}\n```"
    plan = _parse_plan_response(wrapped, "what is quantum computing")
    assert len(plan.steps) == 1
    assert plan.steps[0].type == "LLM_ONLY"


def test_parse_malformed_json_raises():
    with pytest.raises(ValueError):
        _parse_plan_response("not json {{{", "test")


def test_parse_missing_steps_raises():
    with pytest.raises(ValueError, match="missing 'steps'"):
        _parse_plan_response('{"reasoning": "no steps here"}', "test")


def test_parse_empty_steps_raises():
    with pytest.raises(ValueError, match="empty 'steps'"):
        _parse_plan_response('{"steps": []}', "test")


def test_parse_invalid_step_type_raises():
    bad = json.dumps({
        "steps": [{"type": "INVALID_TYPE", "description": "bad"}],
    })
    with pytest.raises(ValueError, match="invalid type"):
        _parse_plan_response(bad, "test")


def test_parse_missing_skill_name_raises():
    bad = json.dumps({
        "steps": [{"type": "EXISTING_SKILL", "description": "no skill name"}],
    })
    with pytest.raises(ValueError, match="missing 'skill'"):
        _parse_plan_response(bad, "test")


def test_parse_missing_capability_raises():
    bad = json.dumps({
        "steps": [{"type": "NEW_SKILL", "description": "no capability"}],
    })
    with pytest.raises(ValueError, match="missing 'capability'"):
        _parse_plan_response(bad, "test")


def test_parse_optional_reasoning():
    no_reasoning = json.dumps({
        "steps": [
            {"type": "LLM_ONLY", "description": "just answer"},
        ],
    })
    plan = _parse_plan_response(no_reasoning, "test")
    assert plan.reasoning == ""


def test_parse_extra_fields_ignored():
    with_extra = json.dumps({
        "steps": [
            {
                "type": "LLM_ONLY",
                "description": "answer",
                "extra_field": "ignored",
                "another": 42,
            },
        ],
        "reasoning": "ok",
        "bonus": "also ignored",
    })
    plan = _parse_plan_response(with_extra, "test")
    assert len(plan.steps) == 1
    assert plan.steps[0].type == "LLM_ONLY"


def test_parse_llm_only_prompt_defaults_to_description():
    """LLM_ONLY step without prompt should use description as prompt."""
    data = json.dumps({
        "steps": [
            {"type": "LLM_ONLY", "description": "Explain something"},
        ],
    })
    plan = _parse_plan_response(data, "test")
    assert plan.steps[0].prompt == "Explain something"


def test_parse_existing_skill_input_defaults_to_empty():
    """EXISTING_SKILL without input should default to empty dict."""
    data = json.dumps({
        "steps": [
            {"type": "EXISTING_SKILL", "skill": "shell_run", "description": "Run command"},
        ],
    })
    plan = _parse_plan_response(data, "test")
    assert plan.steps[0].input == {}


# ── Planning with mock LLM ──────────────────────────────────────────────────


def test_plan_multi_step_request(registry):
    provider = MockProvider([MULTI_STEP_RESPONSE])
    plan = asyncio.run(plan_request("compare prices", provider=provider, registry=registry))
    assert len(plan.steps) == 3
    assert plan.original_request == "compare prices"


def test_plan_simple_question(registry):
    provider = MockProvider([QUESTION_RESPONSE])
    plan = asyncio.run(plan_request("what is quantum computing", provider=provider, registry=registry))
    assert len(plan.steps) == 1
    assert plan.steps[0].type == "LLM_ONLY"


def test_plan_single_skill_match(registry):
    provider = MockProvider([SINGLE_SKILL_RESPONSE])
    plan = asyncio.run(plan_request("fetch example.com", provider=provider, registry=registry))
    assert len(plan.steps) == 1
    assert plan.steps[0].type == "EXISTING_SKILL"
    assert plan.steps[0].skill == "web_fetch"


def test_plan_new_skills_needed(registry):
    provider = MockProvider([NEW_SKILLS_RESPONSE])
    plan = asyncio.run(plan_request("convert CSV and chart it", provider=provider, registry=registry))
    assert len(plan.steps) == 2
    assert all(s.type == "NEW_SKILL" for s in plan.steps)


def test_plan_includes_skills_in_prompt(registry):
    """Verify the LLM prompt includes available skill names."""
    registry.register(name="web_fetch", code="def run(): pass", description="Fetch a URL")

    prompts_received = []

    class CapturingProvider(LLMProvider):
        name = "capture"

        async def complete(self, system_prompt: str, user_prompt: str) -> str:
            prompts_received.append(user_prompt)
            return SINGLE_SKILL_RESPONSE

    asyncio.run(plan_request("fetch something", provider=CapturingProvider(), registry=registry))
    assert "web_fetch" in prompts_received[0]
    assert "Fetch a URL" in prompts_received[0]


# ── Error handling tests ─────────────────────────────────────────────────────


def test_plan_malformed_response_retries(registry):
    """First response is bad JSON, second is good — should succeed."""
    from rich.console import Console

    provider = MockProvider(["not json at all", MULTI_STEP_RESPONSE])
    plan = asyncio.run(
        plan_request("test", provider=provider, registry=registry, console=Console(quiet=True))
    )
    assert len(plan.steps) == 3
    assert provider._call_count == 2


def test_plan_all_retries_fail_fallback(registry):
    """All responses are garbage — should fall back to LLM_ONLY."""
    from rich.console import Console

    provider = MockProvider(["garbage", "more garbage"])
    plan = asyncio.run(
        plan_request("test", provider=provider, registry=registry, console=Console(quiet=True))
    )
    assert len(plan.steps) == 1
    assert plan.steps[0].type == "LLM_ONLY"
    assert "falling back" in plan.reasoning.lower()


def test_plan_no_skills_available(registry):
    """Empty registry should still produce a valid plan."""
    provider = MockProvider([QUESTION_RESPONSE])
    plan = asyncio.run(plan_request("hello", provider=provider, registry=registry))
    assert len(plan.steps) >= 1


# ── Fallback tests ───────────────────────────────────────────────────────────


def test_fallback_plan():
    plan = _fallback_plan("test request")
    assert len(plan.steps) == 1
    assert plan.steps[0].type == "LLM_ONLY"
    assert plan.steps[0].prompt == "test request"
    assert plan.original_request == "test request"


# ── Display tests ────────────────────────────────────────────────────────────


def test_display_plan_no_crash():
    """display_plan should not raise for any valid plan."""
    from rich.console import Console

    plan = _parse_plan_response(MULTI_STEP_RESPONSE, "test")
    display_plan(plan, console=Console(quiet=True))  # Should not raise


def test_display_plan_single_step():
    from rich.console import Console

    plan = _parse_plan_response(QUESTION_RESPONSE, "question")
    display_plan(plan, console=Console(quiet=True))


# ── Additional edge-case tests ───────────────────────────────────────────────


def test_extract_json_array_not_dict():
    """A JSON array (not dict) should be rejected."""
    with pytest.raises(ValueError, match="Could not parse JSON"):
        _extract_json('[1, 2, 3]')


def test_extract_json_nested_code_blocks():
    """JSON in code block that itself contains backticks in string values."""
    inner_json = json.dumps({"steps": [{"type": "LLM_ONLY", "description": "test `value`"}]})
    response = f"```json\n{inner_json}\n```"
    data = _extract_json(response)
    assert data["steps"][0]["description"] == "test `value`"


def test_parse_step_not_dict():
    """Non-dict element in steps array should raise ValueError."""
    bad = json.dumps({"steps": ["not a dict"]})
    with pytest.raises(ValueError, match="Step 0 is not a dict"):
        _parse_plan_response(bad, "test")


def test_parse_step_missing_description():
    """Step without description should raise ValueError."""
    bad = json.dumps({
        "steps": [{"type": "LLM_ONLY"}],
    })
    with pytest.raises(ValueError, match="missing 'description'"):
        _parse_plan_response(bad, "test")


def test_parse_empty_skill_name():
    """EXISTING_SKILL with empty string skill name should raise."""
    bad = json.dumps({
        "steps": [{"type": "EXISTING_SKILL", "skill": "", "description": "x"}],
    })
    with pytest.raises(ValueError, match="missing 'skill'"):
        _parse_plan_response(bad, "test")


def test_parse_empty_capability():
    """NEW_SKILL with empty capability should raise."""
    bad = json.dumps({
        "steps": [{"type": "NEW_SKILL", "capability": "", "description": "x"}],
    })
    with pytest.raises(ValueError, match="missing 'capability'"):
        _parse_plan_response(bad, "test")


def test_parse_steps_not_list():
    """Steps field that is not a list should raise."""
    bad = json.dumps({"steps": "not a list"})
    with pytest.raises(ValueError, match="missing 'steps'"):
        _parse_plan_response(bad, "test")


def test_parse_many_steps():
    """Plan with many steps should all be parsed."""
    steps = [
        {"type": "LLM_ONLY", "description": f"step {i}"}
        for i in range(10)
    ]
    data = json.dumps({"steps": steps, "reasoning": "many steps"})
    plan = _parse_plan_response(data, "complex request")
    assert len(plan.steps) == 10
    assert all(s.type == "LLM_ONLY" for s in plan.steps)


def test_plan_request_retry_prompt_includes_suffix(registry):
    """On first parse failure, retry prompt should include the retry suffix."""
    prompts_received = []

    class CapturingProvider(LLMProvider):
        name = "capture"

        async def complete(self, system_prompt: str, user_prompt: str) -> str:
            prompts_received.append(user_prompt)
            if len(prompts_received) == 1:
                return "not json"
            return QUESTION_RESPONSE

    from rich.console import Console

    asyncio.run(plan_request("test", provider=CapturingProvider(), registry=registry, console=Console(quiet=True)))
    assert len(prompts_received) == 2
    assert "not valid JSON" in prompts_received[1]


def test_display_plan_all_step_types():
    """display_plan handles all three step types without crashing."""
    from rich.console import Console

    plan = _parse_plan_response(MULTI_STEP_RESPONSE, "test")
    assert len(plan.steps) == 3
    display_plan(plan, console=Console(quiet=True))


def test_display_plan_no_reasoning():
    """display_plan handles plan with empty reasoning."""
    from rich.console import Console

    plan = ExecutionPlan(
        steps=[PlanStep(type="LLM_ONLY", description="test")],
        reasoning="",
        original_request="test",
    )
    display_plan(plan, console=Console(quiet=True))


def test_fallback_plan_preserves_request():
    plan = _fallback_plan("a very long request about many things")
    assert plan.original_request == "a very long request about many things"
    assert plan.steps[0].prompt == "a very long request about many things"
