"""Tests for output styles module."""

from __future__ import annotations

from pathlib import Path
from typing import Any

import pytest
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel

from pydantic_deep import create_deep_agent
from pydantic_deep.styles import (
    BUILTIN_STYLES,
    BULLET_STYLE,
    CONCISE_STYLE,
    CONVERSATIONAL_STYLE,
    EXPLANATORY_STYLE,
    FORMAL_STYLE,
    JSON_ONLY_STYLE,
    MARKDOWN_STYLE,
    OutputStyle,
    discover_styles,
    format_style_prompt,
    load_style_from_file,
    resolve_style,
)

TEST_MODEL = TestModel()


def _minimal_agent(**kwargs: Any) -> Any:
    """Create agent with minimal toolsets for fast tests."""
    defaults = {
        "model": TEST_MODEL,
        "include_subagents": False,
        "include_skills": False,
        "cost_tracking": False,
        "context_manager": False,
    }
    defaults.update(kwargs)
    return create_deep_agent(**defaults)  # type: ignore[call-overload]


class TestOutputStyle:
    """Tests for OutputStyle dataclass."""

    def test_create(self) -> None:
        """OutputStyle can be created with all fields."""
        style = OutputStyle(name="test", description="A test style", content="Be brief.")
        assert style.name == "test"
        assert style.description == "A test style"
        assert style.content == "Be brief."

    def test_equality(self) -> None:
        """Two OutputStyles with same fields are equal."""
        a = OutputStyle(name="x", description="y", content="z")
        b = OutputStyle(name="x", description="y", content="z")
        assert a == b

    def test_empty_description(self) -> None:
        """OutputStyle works with empty description."""
        style = OutputStyle(name="test", description="", content="content")
        assert style.description == ""


class TestBuiltinStyles:
    """Tests for built-in output styles."""

    def test_all_builtins_exist(self) -> None:
        """All 7 built-in styles are registered."""
        assert len(BUILTIN_STYLES) == 7
        assert set(BUILTIN_STYLES.keys()) == {
            "concise",
            "explanatory",
            "formal",
            "conversational",
            "markdown",
            "json-only",
            "bullet",
        }

    def test_concise_style(self) -> None:
        """Concise style has correct fields."""
        assert CONCISE_STYLE.name == "concise"
        assert CONCISE_STYLE.description
        assert "concise" in CONCISE_STYLE.content.lower()

    def test_explanatory_style(self) -> None:
        """Explanatory style has correct fields."""
        assert EXPLANATORY_STYLE.name == "explanatory"
        assert EXPLANATORY_STYLE.description
        assert "explain" in EXPLANATORY_STYLE.content.lower()

    def test_formal_style(self) -> None:
        """Formal style has correct fields."""
        assert FORMAL_STYLE.name == "formal"
        assert FORMAL_STYLE.description
        assert "professional" in FORMAL_STYLE.content.lower()

    def test_conversational_style(self) -> None:
        """Conversational style has correct fields."""
        assert CONVERSATIONAL_STYLE.name == "conversational"
        assert CONVERSATIONAL_STYLE.description
        assert "friendly" in CONVERSATIONAL_STYLE.content.lower()

    def test_markdown_style(self) -> None:
        """Markdown style has correct fields."""
        assert MARKDOWN_STYLE.name == "markdown"
        assert MARKDOWN_STYLE.description
        assert "markdown" in MARKDOWN_STYLE.content.lower()
        assert "fenced code block" in MARKDOWN_STYLE.content.lower()

    def test_json_only_style(self) -> None:
        """JSON-only style has correct fields."""
        assert JSON_ONLY_STYLE.name == "json-only"
        assert JSON_ONLY_STYLE.description
        assert "json" in JSON_ONLY_STYLE.content.lower()
        # No preamble / no fences are the key constraints
        assert "no preamble" in JSON_ONLY_STYLE.content.lower()
        assert "no markdown code fences" in JSON_ONLY_STYLE.content.lower()

    def test_bullet_style(self) -> None:
        """Bullet style has correct fields."""
        assert BULLET_STYLE.name == "bullet"
        assert BULLET_STYLE.description
        assert "bullet" in BULLET_STYLE.content.lower()
        assert "no paragraphs" in BULLET_STYLE.content.lower()

    def test_registry_values_match_constants(self) -> None:
        """Registry values are the same objects as module constants."""
        assert BUILTIN_STYLES["concise"] is CONCISE_STYLE
        assert BUILTIN_STYLES["explanatory"] is EXPLANATORY_STYLE
        assert BUILTIN_STYLES["formal"] is FORMAL_STYLE
        assert BUILTIN_STYLES["conversational"] is CONVERSATIONAL_STYLE
        assert BUILTIN_STYLES["markdown"] is MARKDOWN_STYLE
        assert BUILTIN_STYLES["json-only"] is JSON_ONLY_STYLE
        assert BUILTIN_STYLES["bullet"] is BULLET_STYLE


class TestResolveStyle:
    """Tests for resolve_style function."""

    def test_resolve_builtin_by_name(self) -> None:
        """Resolves a built-in style by name."""
        style = resolve_style("concise")
        assert style is CONCISE_STYLE

    def test_resolve_markdown_style(self) -> None:
        """Resolves the markdown built-in by name."""
        assert resolve_style("markdown") is MARKDOWN_STYLE

    def test_resolve_json_only_style(self) -> None:
        """Resolves the json-only built-in by name (hyphenated)."""
        assert resolve_style("json-only") is JSON_ONLY_STYLE

    def test_resolve_bullet_style(self) -> None:
        """Resolves the bullet built-in by name."""
        assert resolve_style("bullet") is BULLET_STYLE

    def test_resolve_all_builtins(self) -> None:
        """All built-in names resolve correctly."""
        for name, expected in BUILTIN_STYLES.items():
            assert resolve_style(name) is expected

    def test_passthrough_output_style(self) -> None:
        """OutputStyle instance is passed through unchanged."""
        custom = OutputStyle(name="custom", description="Custom", content="Do X")
        assert resolve_style(custom) is custom

    def test_unknown_name_raises(self) -> None:
        """Unknown style name raises ValueError."""
        with pytest.raises(ValueError, match="Unknown output style 'nonexistent'"):
            resolve_style("nonexistent")

    def test_unknown_name_lists_available(self) -> None:
        """Error message lists available built-in styles."""
        with pytest.raises(ValueError, match="concise"):
            resolve_style("nope")

    def test_resolve_from_styles_dir(self, tmp_path: Path) -> None:
        """Resolves a custom style from styles_dir."""
        style_file = tmp_path / "my-style.md"
        style_file.write_text(
            "---\nname: my-style\ndescription: Custom\n---\n\nBe custom.",
            encoding="utf-8",
        )
        style = resolve_style("my-style", styles_dir=str(tmp_path))
        assert style.name == "my-style"
        assert style.content == "Be custom."

    def test_resolve_from_styles_dir_list(self, tmp_path: Path) -> None:
        """Resolves from a list of directories."""
        dir1 = tmp_path / "dir1"
        dir2 = tmp_path / "dir2"
        dir1.mkdir()
        dir2.mkdir()
        style_file = dir2 / "special.md"
        style_file.write_text(
            "---\nname: special\ndescription: Special\n---\n\nBe special.",
            encoding="utf-8",
        )
        style = resolve_style("special", styles_dir=[str(dir1), str(dir2)])
        assert style.name == "special"

    def test_resolve_builtin_takes_precedence(self, tmp_path: Path) -> None:
        """Built-in style takes precedence over directory style with same name."""
        style_file = tmp_path / "concise.md"
        style_file.write_text(
            "---\nname: concise\ndescription: Override\n---\n\nOverridden.",
            encoding="utf-8",
        )
        style = resolve_style("concise", styles_dir=str(tmp_path))
        assert style is CONCISE_STYLE

    def test_resolve_not_found_in_dir(self, tmp_path: Path) -> None:
        """Raises ValueError when not found in built-ins or directories."""
        with pytest.raises(ValueError, match="Unknown output style"):
            resolve_style("missing", styles_dir=str(tmp_path))


class TestLoadStyleFromFile:
    """Tests for load_style_from_file function."""

    def test_valid_file(self, tmp_path: Path) -> None:
        """Loads style from valid markdown file."""
        f = tmp_path / "test.md"
        f.write_text(
            "---\nname: test-style\ndescription: A test\n---\n\nDo things.",
            encoding="utf-8",
        )
        style = load_style_from_file(f)
        assert style.name == "test-style"
        assert style.description == "A test"
        assert style.content == "Do things."

    def test_missing_name_raises(self, tmp_path: Path) -> None:
        """Raises ValueError when frontmatter has no name."""
        f = tmp_path / "no-name.md"
        f.write_text("---\ndescription: No name\n---\n\nContent.", encoding="utf-8")
        with pytest.raises(ValueError, match="must have a 'name'"):
            load_style_from_file(f)

    def test_empty_body_raises(self, tmp_path: Path) -> None:
        """Raises ValueError when frontmatter is present but body is blank."""
        f = tmp_path / "no-body.md"
        f.write_text("---\nname: empty-body\ndescription: Has name\n---\n\n   \n", encoding="utf-8")
        with pytest.raises(ValueError, match="has no content body"):
            load_style_from_file(f)

    def test_missing_file_raises(self, tmp_path: Path) -> None:
        """Raises FileNotFoundError for non-existent file."""
        with pytest.raises(FileNotFoundError):
            load_style_from_file(tmp_path / "nope.md")

    def test_no_frontmatter(self, tmp_path: Path) -> None:
        """File without frontmatter raises ValueError (no name)."""
        f = tmp_path / "plain.md"
        f.write_text("Just plain content.\n", encoding="utf-8")
        with pytest.raises(ValueError, match="must have a 'name'"):
            load_style_from_file(f)

    def test_missing_description(self, tmp_path: Path) -> None:
        """Missing description defaults to empty string."""
        f = tmp_path / "no-desc.md"
        f.write_text("---\nname: minimal\n---\n\nMinimal style.", encoding="utf-8")
        style = load_style_from_file(f)
        assert style.name == "minimal"
        assert style.description == ""

    def test_string_path(self, tmp_path: Path) -> None:
        """Accepts string path."""
        f = tmp_path / "str-path.md"
        f.write_text("---\nname: strpath\n---\n\nContent.", encoding="utf-8")
        style = load_style_from_file(str(f))
        assert style.name == "strpath"


class TestDiscoverStyles:
    """Tests for discover_styles function."""

    def test_discover_multiple(self, tmp_path: Path) -> None:
        """Discovers multiple style files."""
        for name in ["alpha", "beta"]:
            f = tmp_path / f"{name}.md"
            f.write_text(
                f"---\nname: {name}\ndescription: {name.title()}\n---\n\n{name} content.",
                encoding="utf-8",
            )
        styles = discover_styles(tmp_path)
        assert len(styles) == 2
        assert "alpha" in styles
        assert "beta" in styles

    def test_discover_skips_invalid(self, tmp_path: Path) -> None:
        """Skips files without valid frontmatter."""
        good = tmp_path / "good.md"
        good.write_text("---\nname: good\n---\n\nGood content.", encoding="utf-8")
        bad = tmp_path / "bad.md"
        bad.write_text("No frontmatter here.\n", encoding="utf-8")
        styles = discover_styles(tmp_path)
        assert len(styles) == 1
        assert "good" in styles

    def test_discover_empty_dir(self, tmp_path: Path) -> None:
        """Returns empty dict for empty directory."""
        styles = discover_styles(tmp_path)
        assert styles == {}

    def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
        """Returns empty dict for non-existent directory."""
        styles = discover_styles(tmp_path / "nope")
        assert styles == {}

    def test_discover_ignores_non_md(self, tmp_path: Path) -> None:
        """Ignores non-markdown files."""
        txt = tmp_path / "style.txt"
        txt.write_text("---\nname: txt\n---\n\nContent.", encoding="utf-8")
        md = tmp_path / "style.md"
        md.write_text("---\nname: md\n---\n\nContent.", encoding="utf-8")
        styles = discover_styles(tmp_path)
        assert len(styles) == 1
        assert "md" in styles

    def test_discover_string_path(self, tmp_path: Path) -> None:
        """Accepts string path."""
        f = tmp_path / "s.md"
        f.write_text("---\nname: s\n---\n\nContent.", encoding="utf-8")
        styles = discover_styles(str(tmp_path))
        assert "s" in styles


class TestFormatStylePrompt:
    """Tests for format_style_prompt function."""

    def test_format_builtin(self) -> None:
        """Formats a built-in style correctly."""
        result = format_style_prompt(CONCISE_STYLE)
        assert result.startswith("## Output Style: concise")
        assert "concise" in result.lower()

    def test_format_custom(self) -> None:
        """Formats a custom style correctly."""
        style = OutputStyle(name="custom", description="Custom", content="Do X\nDo Y")
        result = format_style_prompt(style)
        assert result == "## Output Style: custom\n\nDo X\nDo Y"

    def test_format_includes_content(self) -> None:
        """Content is included verbatim."""
        style = OutputStyle(name="t", description="", content="Line 1\nLine 2")
        result = format_style_prompt(style)
        assert "Line 1\nLine 2" in result


class TestCreateDeepAgentWithStyle:
    """Tests for output_style parameter in create_deep_agent."""

    def _get_static_instructions(self, agent: Any) -> str:
        """Extract the static instructions string from an agent."""
        instructions = agent._instructions
        if isinstance(instructions, list):
            return instructions[0]  # type: ignore[no-any-return]
        return instructions  # type: ignore[no-any-return]  # pragma: no cover

    def test_no_style_by_default(self) -> None:
        """Default agent has no style in instructions."""
        agent = _minimal_agent()
        assert isinstance(agent, Agent)
        static = self._get_static_instructions(agent)
        assert "Output Style" not in static

    def test_builtin_style_injected(self) -> None:
        """Built-in style is injected into instructions."""
        agent = _minimal_agent(output_style="concise")
        static = self._get_static_instructions(agent)
        assert "## Output Style: concise" in static

    def test_all_builtin_styles(self) -> None:
        """All built-in style names work."""
        for name in BUILTIN_STYLES:
            agent = _minimal_agent(output_style=name)
            static = self._get_static_instructions(agent)
            assert f"## Output Style: {name}" in static

    def test_custom_output_style_instance(self) -> None:
        """Custom OutputStyle instance is injected."""
        custom = OutputStyle(name="my-style", description="Mine", content="Be awesome.")
        agent = _minimal_agent(output_style=custom)
        static = self._get_static_instructions(agent)
        assert "## Output Style: my-style" in static
        assert "Be awesome." in static

    def test_style_appended_to_instructions(self) -> None:
        """Style is appended after custom instructions (which replace BASE_PROMPT)."""
        agent = _minimal_agent(instructions="Base prompt.", output_style="formal")
        static = self._get_static_instructions(agent)
        assert "Base prompt." in static
        assert "## Output Style: formal" in static

    def test_style_with_custom_instructions(self) -> None:
        """Custom instructions replace BASE_PROMPT; style is appended after."""
        agent = _minimal_agent(instructions="You are helpful.", output_style="explanatory")
        static = self._get_static_instructions(agent)
        assert "You are helpful." in static
        assert "## Output Style: explanatory" in static

    def test_style_from_directory(self, tmp_path: Path) -> None:
        """Loads style from styles_dir."""
        f = tmp_path / "custom.md"
        f.write_text(
            "---\nname: custom\ndescription: Custom\n---\n\nBe custom.",
            encoding="utf-8",
        )
        agent = _minimal_agent(output_style="custom", styles_dir=str(tmp_path))
        static = self._get_static_instructions(agent)
        assert "## Output Style: custom" in static
        assert "Be custom." in static

    def test_invalid_style_name_raises(self) -> None:
        """Unknown style name raises ValueError."""
        with pytest.raises(ValueError, match="Unknown output style"):
            _minimal_agent(output_style="nonexistent")

    def test_none_style_no_change(self) -> None:
        """output_style=None leaves instructions unchanged."""
        agent1 = _minimal_agent(instructions="Test")
        agent2 = _minimal_agent(instructions="Test", output_style=None)
        static1 = self._get_static_instructions(agent1)
        static2 = self._get_static_instructions(agent2)
        assert static1 == static2

    def test_style_with_capabilities(self) -> None:
        """Style works when capabilities are active."""
        agent = _minimal_agent(
            output_style="concise",
            cost_tracking=True,
            context_manager=True,
        )
        # Agent with capabilities (no wrapping needed)
        static = self._get_static_instructions(agent)
        assert "## Output Style: concise" in static


class TestStyleExports:
    """Tests for style types exported from pydantic_deep."""

    def test_output_style_importable(self) -> None:
        """OutputStyle is importable from pydantic_deep."""
        from pydantic_deep import OutputStyle

        assert OutputStyle is not None

    def test_builtin_styles_importable(self) -> None:
        """BUILTIN_STYLES is importable from pydantic_deep."""
        from pydantic_deep import BUILTIN_STYLES

        assert BUILTIN_STYLES is not None
        assert len(BUILTIN_STYLES) == 7

    def test_resolve_style_importable(self) -> None:
        """resolve_style is importable from pydantic_deep."""
        from pydantic_deep import resolve_style

        assert callable(resolve_style)

    def test_load_style_from_file_importable(self) -> None:
        """load_style_from_file is importable from pydantic_deep."""
        from pydantic_deep import load_style_from_file

        assert callable(load_style_from_file)

    def test_discover_styles_importable(self) -> None:
        """discover_styles is importable from pydantic_deep."""
        from pydantic_deep import discover_styles

        assert callable(discover_styles)

    def test_format_style_prompt_importable(self) -> None:
        """format_style_prompt is importable from pydantic_deep."""
        from pydantic_deep import format_style_prompt

        assert callable(format_style_prompt)
