"""Tests for codebase context injection in loom.skills.decomposer."""

from __future__ import annotations

import pytest
from jinja2 import Template

from loom.scanner import CodebaseSummary, FileSymbols
from loom.skills.decomposer import (
    CODEBASE_CONTEXT_SENTINEL,
    CODEBASE_CONTEXT_TOKEN_BUDGET,
    build_codebase_context,
)
from loom.skills.loader import load_skill


# ── Helpers ──────────────────────────────────────────────────────────


def _make_summary(*file_specs: tuple[str, list[str], list[str]]) -> CodebaseSummary:
    """Build a CodebaseSummary from (path, functions, classes) tuples."""
    files = [
        FileSymbols(path=path, functions=funcs, classes=classes)
        for path, funcs, classes in file_specs
    ]
    return CodebaseSummary(root="/tmp/test-project", files=files)


def _render_decompose_prompt(
    goal: str,
    context: str,
    depth: int = 3,
) -> str:
    """Render the decompose_project skill template with the given inputs.

    Mirrors the prompt assembly path used in decompose():
      1. build_codebase_context() produces context prefix
      2. merged context is passed to the Jinja2 template
    """
    skill = load_skill("decompose_project")
    return Template(skill.prompt_template).render(
        goal=goal, context=context, depth=depth,
    )


# ── Tests: build_codebase_context ────────────────────────────────────


class TestBuildCodebaseContextEmpty:
    """build_codebase_context returns empty string for None/empty inputs."""

    def test_none_summary_returns_empty(self) -> None:
        result = build_codebase_context(None)
        assert result == ""

    def test_empty_files_returns_empty(self) -> None:
        summary = CodebaseSummary(root="/tmp/proj", files=[])
        result = build_codebase_context(summary)
        assert result == ""


class TestBuildCodebaseContextNonEmpty:
    """Non-empty summaries produce a context block with sentinel and symbols."""

    def test_sentinel_present(self) -> None:
        summary = _make_summary(("app.py", ["run"], ["App"]))
        result = build_codebase_context(summary)
        assert CODEBASE_CONTEXT_SENTINEL in result

    def test_symbols_appear_in_output(self) -> None:
        summary = _make_summary(
            ("src/server.py", ["handle_request", "start"], ["Server"]),
            ("lib/utils.py", ["format_date"], []),
        )
        result = build_codebase_context(summary)

        assert "handle_request" in result
        assert "start" in result
        assert "Server" in result
        assert "format_date" in result
        assert "src/server.py" in result
        assert "lib/utils.py" in result

    def test_instruction_rules_present(self) -> None:
        """The imperative rules telling the LLM what to do are included."""
        summary = _make_summary(("x.py", ["f"], []))
        result = build_codebase_context(summary)

        assert "DO NOT generate tasks" in result
        assert "partially complete" in result
        assert "Fold trivially small tasks" in result

    def test_file_paths_in_output(self) -> None:
        summary = _make_summary(
            ("loom/graph/store.py", ["create_task", "get_task"], ["TaskStore"]),
        )
        result = build_codebase_context(summary)
        assert "loom/graph/store.py" in result


class TestBuildCodebaseContextTruncation:
    """Token budget truncation works correctly."""

    def test_truncation_with_small_budget(self) -> None:
        """Many files with a tiny token budget should trigger truncation."""
        files = [
            (f"mod_{i:03d}.py", [f"func_{i}_a", f"func_{i}_b"], [f"Class{i}"])
            for i in range(50)
        ]
        summary = _make_summary(*files)

        result = build_codebase_context(summary, max_tokens=50)

        # Sentinel and at least one file must still appear
        assert CODEBASE_CONTEXT_SENTINEL in result
        assert "## mod_" in result
        # Truncation notice from CodebaseSummary.to_prompt_str
        assert "[truncated:" in result
        assert "files omitted]" in result

    def test_full_budget_includes_all(self) -> None:
        """With a large budget, all files appear and no truncation notice."""
        summary = _make_summary(
            ("a.py", ["x"], []),
            ("b.py", ["y"], []),
        )
        result = build_codebase_context(summary, max_tokens=10000)

        assert "a.py" in result
        assert "b.py" in result
        assert "[truncated:" not in result

    def test_default_budget_is_module_constant(self) -> None:
        """The default max_tokens matches the module-level constant."""
        assert CODEBASE_CONTEXT_TOKEN_BUDGET == 2000


# ── Tests: context appears before goal in rendered prompt ────────────


class TestContextBeforeGoalInPrompt:
    """Codebase context must appear before the goal in the final prompt."""

    def test_context_before_goal(self) -> None:
        summary = _make_summary(("core.py", ["main_loop"], ["Engine"]))
        codebase_ctx = build_codebase_context(summary)

        user_context = "Some extra user notes"
        merged = (codebase_ctx + "\n" + user_context).strip()

        prompt = _render_decompose_prompt(
            goal="Build the widget system",
            context=merged,
        )

        sentinel_pos = prompt.index(CODEBASE_CONTEXT_SENTINEL)
        goal_pos = prompt.index("Build the widget system")

        assert sentinel_pos < goal_pos, (
            "Codebase context sentinel must appear before the goal in the prompt"
        )

    def test_user_context_also_present(self) -> None:
        """User-supplied context is preserved alongside codebase context."""
        summary = _make_summary(("api.py", ["get", "post"], ["Router"]))
        codebase_ctx = build_codebase_context(summary)

        user_context = "Remember to handle authentication"
        merged = (codebase_ctx + "\n" + user_context).strip()

        prompt = _render_decompose_prompt(
            goal="Add REST endpoints",
            context=merged,
        )

        assert "Remember to handle authentication" in prompt
        assert "Router" in prompt

    def test_no_summary_no_sentinel(self) -> None:
        """When no codebase summary is provided, the sentinel must not appear."""
        codebase_ctx = build_codebase_context(None)
        merged = codebase_ctx or "plain user context"

        prompt = _render_decompose_prompt(
            goal="Do something",
            context=merged,
        )

        assert CODEBASE_CONTEXT_SENTINEL not in prompt


class TestPromptWithEmptySummary:
    """Edge cases for the merged context in the decompose flow."""

    def test_empty_summary_no_context(self) -> None:
        """Neither codebase summary nor user context produces minimal prompt."""
        codebase_ctx = build_codebase_context(None)
        merged = (codebase_ctx + "\n" + "").strip() if codebase_ctx else ""

        prompt = _render_decompose_prompt(goal="Build it", context=merged)

        # Goal should still appear
        assert "Build it" in prompt
        # No codebase context block
        assert CODEBASE_CONTEXT_SENTINEL not in prompt

    def test_empty_summary_with_user_context(self) -> None:
        """User context alone (no codebase) works correctly."""
        codebase_ctx = build_codebase_context(CodebaseSummary(root="/x", files=[]))
        user_context = "Use FastAPI"
        merged = (codebase_ctx + "\n" + user_context).strip() if codebase_ctx else user_context

        prompt = _render_decompose_prompt(goal="API", context=merged)

        assert "Use FastAPI" in prompt
        assert CODEBASE_CONTEXT_SENTINEL not in prompt
