"""Comprehensive tests for Kida error handling.

This module tests error messages, exception types, and error recovery
to ensure that users get helpful diagnostics when things go wrong.
"""

from __future__ import annotations

import contextlib

import pytest

from kida import DictLoader, Environment
from kida.environment.exceptions import (
    ErrorCode,
    TemplateNotFoundError,
    TemplateRuntimeError,
    TemplateSyntaxError,
    UndefinedError,
    build_source_snippet,
)
from tests.conftest import strip_ansi


class TestSyntaxErrors:
    """Test syntax error detection and messages."""

    def test_unclosed_variable(self) -> None:
        """Unclosed variable expression."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)) as exc_info:
            env.from_string("{{ x")
        # Should have useful error message
        assert exc_info.value is not None

    def test_unclosed_block(self) -> None:
        """Unclosed block tag."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)) as exc_info:
            env.from_string("{% if true")
        assert exc_info.value is not None

    def test_unmatched_endif(self) -> None:
        """Unmatched endif should raise an error."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{% endif %}")

    def test_unmatched_endfor(self) -> None:
        """Unmatched endfor should raise an error."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{% endfor %}")

    def test_missing_expression(self) -> None:
        """Missing expression in variable output."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{{  }}")

    def test_invalid_expression(self) -> None:
        """Invalid expression syntax."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{{ + + }}")

    def test_unclosed_string(self) -> None:
        """Unclosed string literal."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{{ 'unclosed }}")

    def test_mismatched_block_tags(self) -> None:
        """Mismatched block end tags should raise an error."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{% if true %}{% endfor %}")

    def test_elif_without_if(self) -> None:
        """elif without if should raise an error."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{% elif true %}{% endif %}")

    def test_else_without_if(self) -> None:
        """else without if or for should raise an error."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{% else %}{% endif %}")

    def test_invalid_for_syntax(self) -> None:
        """Invalid for loop syntax."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{% for in items %}{% endfor %}")

    def test_missing_set_value(self) -> None:
        """Set without value."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{% set x %}{{ x }}")

    def test_invalid_filter_syntax(self) -> None:
        """Invalid filter syntax."""
        env = Environment()
        with pytest.raises((TemplateSyntaxError, Exception)):
            env.from_string("{{ x | }}")

    def test_hyphen_in_block_name(self) -> None:
        """Block names cannot contain hyphens — use underscores."""
        from kida.parser.errors import ParseError

        env = Environment()
        with pytest.raises(ParseError) as exc_info:
            env.from_string("{% block settings-status %}x{% end %}").render()
        err = exc_info.value
        assert err.code == ErrorCode.INVALID_IDENTIFIER
        assert "hyphen" in str(err).lower()
        assert "underscores" in str(err).lower()
        assert "settings_status" in str(err)

    def test_hyphen_in_fragment_name(self) -> None:
        """Fragment names cannot contain hyphens."""
        from kida.parser.errors import ParseError

        env = Environment()
        with pytest.raises(ParseError) as exc_info:
            env.from_string("{% fragment nav-bar %}x{% end %}").render()
        err = exc_info.value
        assert err.code == ErrorCode.INVALID_IDENTIFIER
        assert "hyphen" in str(err).lower()


class TestRuntimeErrors:
    """Test runtime error handling."""

    @pytest.fixture
    def env(self) -> Environment:
        return Environment()

    def test_division_by_zero(self, env: Environment) -> None:
        """Division by zero."""
        tmpl = env.from_string("{{ 1 / 0 }}")
        with pytest.raises((ZeroDivisionError, TemplateRuntimeError)):
            tmpl.render()

    def test_type_error_concatenation(self, env: Environment) -> None:
        """Polymorphic + concatenates string + int as strings (no TypeError)."""
        tmpl = env.from_string("{{ 'hello' + 5 }}")
        assert tmpl.render() == "hello5"

    def test_index_out_of_range(self, env: Environment) -> None:
        """Index out of range."""
        tmpl = env.from_string("{{ items[100] }}")
        with pytest.raises((IndexError, TemplateRuntimeError)):
            tmpl.render(items=[1, 2, 3])

    def test_attribute_error(self, env: Environment) -> None:
        """Attribute error on non-existent attribute.

        Note: With None-safe attribute access, this returns empty string.
        """
        tmpl = env.from_string("{{ obj.nonexistent }}")
        result = tmpl.render(obj={})
        # With None-safe access, should return empty string
        assert result == ""

    def test_iteration_over_non_iterable(self, env: Environment) -> None:
        """Iteration over non-iterable."""
        tmpl = env.from_string("{% for x in items %}{{ x }}{% endfor %}")
        with pytest.raises((TypeError, TemplateRuntimeError)):
            tmpl.render(items=42)

    def test_call_non_callable(self, env: Environment) -> None:
        """Calling non-callable."""
        tmpl = env.from_string("{{ x() }}")
        with pytest.raises((TypeError, TemplateRuntimeError)):
            tmpl.render(x=42)

    def test_filter_type_error(self, env: Environment) -> None:
        """Type handling in filter - Kida converts to string before applying."""
        tmpl = env.from_string("{{ x|upper }}")
        # Kida converts int to string before applying upper
        result = tmpl.render(x=42)
        assert result == "42"  # No uppercase effect on digits


class TestUndefinedErrors:
    """Test undefined variable error handling."""

    @pytest.fixture
    def env(self) -> Environment:
        return Environment()

    def test_undefined_raises_in_strict_mode(self, env: Environment) -> None:
        """Undefined variable raises in strict mode."""
        with pytest.raises(UndefinedError) as exc_info:
            env.from_string("{{ undefined_var }}").render()
        assert "undefined_var" in str(exc_info.value)

    def test_undefined_error_includes_variable_name(self, env: Environment) -> None:
        """UndefinedError includes variable name."""
        try:
            env.from_string("{{ my_var }}").render()
            pytest.fail("Expected UndefinedError")
        except UndefinedError as e:
            assert e.name == "my_var"

    def test_undefined_error_includes_template_name(self, env: Environment) -> None:
        """UndefinedError includes template name."""
        try:
            env.from_string("{{ x }}", name="test.html").render()
            pytest.fail("Expected UndefinedError")
        except UndefinedError as e:
            assert "test.html" in str(e)

    def test_default_filter_with_undefined(self, env: Environment) -> None:
        """default filter works with undefined."""
        result = env.from_string('{{ x|default("fallback") }}').render()
        assert result == "fallback"


class TestTemplateNotFoundErrors:
    """Test template not found errors."""

    def test_get_template_not_found(self) -> None:
        """get_template for missing template."""
        loader = DictLoader({})
        env = Environment(loader=loader)
        with pytest.raises((TemplateNotFoundError, Exception)):
            env.get_template("missing.html")

    def test_include_not_found(self) -> None:
        """include of missing template."""
        loader = DictLoader({"main.html": '{% include "missing.html" %}'})
        env = Environment(loader=loader)
        with pytest.raises((TemplateNotFoundError, Exception)):
            env.get_template("main.html").render()

    def test_extends_not_found(self) -> None:
        """extends of missing template."""
        loader = DictLoader({"main.html": '{% extends "missing.html" %}'})
        env = Environment(loader=loader)
        with pytest.raises((TemplateNotFoundError, Exception)):
            env.get_template("main.html").render()

    def test_from_import_not_found(self) -> None:
        """from import of missing template."""
        loader = DictLoader({"main.html": '{% from "missing.html" import x %}'})
        env = Environment(loader=loader)
        with pytest.raises((TemplateNotFoundError, Exception)):
            env.get_template("main.html").render()


class TestErrorContext:
    """Test that errors include context information."""

    def test_error_includes_line_number(self) -> None:
        """Error includes line number."""
        env = Environment()
        template = """line 1
line 2
{{ 1 / 0 }}
line 4"""
        tmpl = env.from_string(template, name="test.html")
        try:
            tmpl.render()
            pytest.fail("Expected error")
        except TemplateRuntimeError as e:
            error_str = str(e)
            assert "test.html" in error_str
            # Should include line 3
            assert ":3" in error_str or "line 3" in error_str.lower()

    def test_error_includes_template_name(self) -> None:
        """Error includes template name."""
        env = Environment()
        tmpl = env.from_string("{{ 1 / 0 }}", name="my_template.html")
        try:
            tmpl.render()
            pytest.fail("Expected error")
        except TemplateRuntimeError as e:
            assert "my_template.html" in str(e)


class TestErrorRecovery:
    """Test error recovery and isolation."""

    def test_error_doesnt_corrupt_environment(self) -> None:
        """Error in one template doesn't corrupt environment."""
        env = Environment()

        # Compile a bad template
        with contextlib.suppress(Exception):
            env.from_string("{{ 1 / 0 }}").render()

        # Environment should still work
        result = env.from_string("{{ x }}").render(x=42)
        assert result == "42"

    def test_multiple_errors_independent(self) -> None:
        """Multiple errors are independent."""
        env = Environment()

        # First error
        with contextlib.suppress(UndefinedError):
            env.from_string("{{ a }}").render()

        # Second error - different variable
        try:
            env.from_string("{{ b }}").render()
        except UndefinedError as e:
            assert e.name == "b"

    def test_partial_render_isolation(self) -> None:
        """Error during render doesn't leave partial state."""
        loader = DictLoader(
            {
                "main.html": "Before {% include 'bad.html' %} After",
                "bad.html": "{{ 1 / 0 }}",
            }
        )
        env_with_loader = Environment(loader=loader)

        try:
            env_with_loader.get_template("main.html").render()
            pytest.fail("Expected error")
        except Exception:
            pass

        # Environment should still be usable
        result = env_with_loader.from_string("{{ x }}").render(x=1)
        assert result == "1"


class TestFilterErrors:
    """Test filter-specific errors."""

    @pytest.fixture
    def env(self) -> Environment:
        return Environment()

    def test_unknown_filter(self, env: Environment) -> None:
        """Unknown filter raises at compile time.

        Note: As of the parser hardening RFC, unknown filters are now caught
        at compile time (from_string) rather than render time.
        """
        with pytest.raises(TemplateSyntaxError, match="Unknown filter"):
            env.from_string("{{ x|nonexistent_filter }}")

    def test_filter_wrong_arg_count(self, env: Environment) -> None:
        """Filter with wrong number of arguments."""
        # truncate requires at least length argument
        tmpl = env.from_string("{{ x|truncate }}")
        # May or may not raise depending on filter defaults
        with contextlib.suppress(Exception):
            tmpl.render(x="hello world")  # Expected

    def test_filter_type_error_message(self, env: Environment) -> None:
        """Filter type error behavior - Kida may convert or raise."""
        tmpl = env.from_string("{{ x|join(',') }}")
        # Kida may silently convert int to string or raise
        try:
            result = tmpl.render(x=42)
            # If no error, result is some string representation
            assert result is not None
        except (TypeError, TemplateRuntimeError) as e:
            # Error should be understandable
            assert str(e) is not None


class TestTestErrors:
    """Test test-related errors."""

    @pytest.fixture
    def env(self) -> Environment:
        return Environment()

    def test_unknown_test(self, env: Environment) -> None:
        """Unknown test raises at compile time.

        Note: As of the parser hardening RFC, unknown tests are now caught
        at compile time (from_string) rather than render time.
        """
        with pytest.raises(TemplateSyntaxError, match="Unknown test"):
            env.from_string("{% if x is nonexistent_test %}yes{% endif %}")


class TestFunctionErrors:
    """Test macro-related errors."""

    @pytest.fixture
    def env(self) -> Environment:
        return Environment()

    def test_function_wrong_arg_count(self, env: Environment) -> None:
        """Function called with wrong number of arguments."""
        tmpl = env.from_string(
            "{% def greet(name, greeting) %}{{ greeting }} {{ name }}{% end %}{{ greet('World') }}"
        )
        # May use default or raise error
        with contextlib.suppress(Exception):
            tmpl.render()  # Expected

    def test_undefined_macro(self, env: Environment) -> None:
        """Calling undefined macro."""
        with pytest.raises((UndefinedError, TemplateRuntimeError, Exception)):
            env.from_string("{{ undefined_macro() }}").render()


class TestInheritanceErrors:
    """Test inheritance-related errors."""

    def test_block_outside_inheritance(self) -> None:
        """Block in template without extends."""
        env = Environment()
        # This should work - blocks can exist without extends
        tmpl = env.from_string("{% block content %}Hello{% endblock %}")
        result = tmpl.render()
        assert "Hello" in result

    def test_duplicate_block_names(self) -> None:
        """Duplicate block names (may or may not be error)."""
        env = Environment()
        # Some engines allow, some don't
        with contextlib.suppress(Exception):
            env.from_string(
                "{% block x %}A{% endblock %}{% block x %}B{% endblock %}"
            ).render()  # May raise

    def test_extends_must_be_first(self) -> None:
        """extends must be first tag (typically)."""
        loader = DictLoader({"base.html": "{% block content %}{% endblock %}"})
        env = Environment(loader=loader)
        with contextlib.suppress(Exception):
            env.from_string('Hello{% extends "base.html" %}').render()  # May raise


class TestLoopErrors:
    """Test loop-related errors."""

    @pytest.fixture
    def env(self) -> Environment:
        return Environment()

    def test_loop_variable_outside_loop(self, env: Environment) -> None:
        """Accessing loop variable outside loop raises UndefinedError."""
        tmpl = env.from_string("{{ loop.index }}")
        # In strict mode (default), accessing undefined 'loop' raises
        with pytest.raises(UndefinedError):
            tmpl.render()

    def test_empty_iterable(self, env: Environment) -> None:
        """Empty iterable in for loop."""
        tmpl = env.from_string("{% for x in items %}{{ x }}{% endfor %}")
        result = tmpl.render(items=[])
        assert result == ""

    def test_break_outside_loop(self, env: Environment) -> None:
        """Break outside loop (if break is supported)."""
        # break may not be supported
        with contextlib.suppress(Exception):
            env.from_string("{% break %}")
            pytest.fail("Should raise for break outside loop")


class TestComplexErrorScenarios:
    """Test complex error scenarios."""

    def test_error_in_included_template(self) -> None:
        """Error in included template shows correct location."""
        loader = DictLoader(
            {
                "main.html": '{% include "partial.html" %}',
                "partial.html": "{{ 1 / 0 }}",
            }
        )
        env = Environment(loader=loader)
        try:
            env.get_template("main.html").render()
            pytest.fail("Expected error")
        except TemplateRuntimeError:
            # Error should reference partial.html
            pass

    def test_enhance_error_includes_exception_type(self) -> None:
        """KeyError(1) yields 'KeyError: 1' not just '1'."""
        loader = DictLoader({"test.html": "{{ items[1] }}"})
        env = Environment(loader=loader)
        with pytest.raises(TemplateRuntimeError) as exc_info:
            env.get_template("test.html").render(items={})
        msg = str(exc_info.value)
        assert "KeyError" in msg
        assert "1" in msg
        # Must not be ambiguous (e.g. just "1")
        assert msg.startswith("Runtime Error:") or "KeyError" in msg.split(":")[0]

    def test_include_error_shows_template_stack(self) -> None:
        """Error in included template shows both templates in stack."""
        loader = DictLoader(
            {
                "tags.html": 'line 1\nline 2\n{% include "partials/tag-nav.html" %}\nline 4',
                "partials/tag-nav.html": "{{ items[1] }}",
            }
        )
        env = Environment(loader=loader)
        with pytest.raises(TemplateRuntimeError) as exc_info:
            env.get_template("tags.html").render(items={})
        err = exc_info.value
        # template_stack = callers; template_name = where error occurred
        assert len(err.template_stack) >= 1
        names = [t[0] for t in err.template_stack]
        assert "tags.html" in names
        assert err.template_name == "partials/tag-nav.html"

    def test_include_error_shows_included_template_location(self) -> None:
        """Location points to included template, not include line."""
        loader = DictLoader(
            {
                "parent.html": "line 1\nline 2\nline 3\n{% include 'child.html' %}\nline 5",
                "child.html": "{{ items[1] }}",
            }
        )
        env = Environment(loader=loader)
        with pytest.raises(TemplateRuntimeError) as exc_info:
            env.get_template("parent.html").render(items={})
        err = exc_info.value
        # Location should be child.html (where error occurred), not parent.html:4
        assert err.template_name == "child.html"

    def test_error_in_inherited_template(self) -> None:
        """Error in inherited template."""
        loader = DictLoader(
            {
                "base.html": "{% block content %}{{ 1 / 0 }}{% endblock %}",
                "child.html": '{% extends "base.html" %}',
            }
        )
        env = Environment(loader=loader)
        try:
            env.get_template("child.html").render()
            pytest.fail("Expected error")
        except TemplateRuntimeError:
            pass

    def test_error_in_macro_body(self) -> None:
        """Error in macro body."""
        env = Environment()
        tmpl = env.from_string("{% def bad() %}{{ 1 / 0 }}{% end %}{{ bad() }}")
        try:
            tmpl.render()
            pytest.fail("Expected error")
        except Exception:
            pass

    def test_error_in_filter_chain(self) -> None:
        """Error in middle of filter chain caught at compile time.

        Note: As of the parser hardening RFC, unknown filters in a chain
        are caught at compile time (from_string) rather than render time.
        """
        env = Environment()
        with pytest.raises(TemplateSyntaxError, match="Unknown filter 'bad_filter'"):
            env.from_string("{{ x|upper|bad_filter|lower }}")

    def test_nested_error_context(self) -> None:
        """Error maintains context through nesting."""
        loader = DictLoader(
            {
                "level1.html": '{% include "level2.html" %}',
                "level2.html": '{% include "level3.html" %}',
                "level3.html": "{{ 1 / 0 }}",
            }
        )
        env = Environment(loader=loader)
        try:
            env.get_template("level1.html").render()
            pytest.fail("Expected error")
        except Exception:
            # Error should be raised
            pass


class TestErrorMessageQuality:
    """Test enriched error messages from the error-consistency epic.

    Validates that errors carry actionable context: "did you mean?"
    suggestions, source snippets with carets, template name hints, etc.
    """

    # -- UndefinedError suggestions ------------------------------------------

    def test_undefined_error_suggests_close_match(self) -> None:
        """Typo in variable name produces 'Did you mean?' suggestion."""
        env = Environment()
        with pytest.raises(UndefinedError) as exc_info:
            env.from_string("{{ titl }}").render(title="Hello")
        assert exc_info.value.name == "titl"
        assert "Did you mean 'title'" in strip_ansi(str(exc_info.value))

    def test_undefined_error_no_suggestion_for_distant_name(self) -> None:
        """No suggestion when no close match exists."""
        env = Environment()
        with pytest.raises(UndefinedError) as exc_info:
            env.from_string("{{ zzzzz }}").render(title="Hello")
        assert "Did you mean" not in str(exc_info.value)

    def test_undefined_error_includes_default_hint(self) -> None:
        """UndefinedError always includes the default() filter hint."""
        env = Environment()
        with pytest.raises(UndefinedError, match=r"default\(''\)") as exc_info:
            env.from_string("{{ missing }}").render()
        assert "Hint:" in str(exc_info.value)

    def test_undefined_error_suggestion_from_context_keys(self) -> None:
        """Suggestion draws from all context keys including builtins."""
        env = Environment()
        # 'items' is in ctx; 'itms' is a typo that is close to 'items'
        tmpl = env.from_string("{{ itms }}")
        with pytest.raises(UndefinedError) as exc_info:
            tmpl.render(items=[1, 2, 3])
        assert "Did you mean 'items'" in strip_ansi(str(exc_info.value))

    # -- TemplateSyntaxError source snippets ---------------------------------

    def test_syntax_error_with_source_shows_snippet(self) -> None:
        """TemplateSyntaxError with source/lineno renders the offending line."""
        err = TemplateSyntaxError(
            "Unexpected token",
            lineno=2,
            filename="test.html",
            source="line one\nbad {{ here\nline three",
        )
        msg = str(err)
        assert "bad {{ here" in msg
        assert "  2 |" in msg or "2 |" in msg

    def test_syntax_error_with_col_offset_shows_caret(self) -> None:
        """Column offset renders a caret pointer under the offending column."""
        err = TemplateSyntaxError(
            "Unexpected token",
            lineno=1,
            filename="test.html",
            source="ok {{ bad_token }}",
            col_offset=5,
        )
        msg = str(err)
        assert "^" in msg
        # Caret should be indented
        assert "     ^" in msg

    def test_syntax_error_without_source_is_compact(self) -> None:
        """Without source, the error is a compact one-liner."""
        err = TemplateSyntaxError(
            "Unexpected token",
            lineno=3,
            filename="test.html",
        )
        msg = str(err)
        assert "test.html:3" in msg
        assert "|" not in msg  # No snippet

    # -- DictLoader template name suggestions --------------------------------

    def test_dictloader_suggests_close_template_name(self) -> None:
        """Typo in template name produces 'Did you mean?' suggestion."""
        loader = DictLoader({"base.html": "hi", "page.html": "there"})
        with pytest.raises(TemplateNotFoundError, match=r"Did you mean 'base\.html'"):
            loader.get_source("base.htm")

    def test_dictloader_lists_available_when_no_close_match(self) -> None:
        """When no close match, available template names are listed."""
        loader = DictLoader({"alpha.html": "a", "beta.html": "b"})
        with pytest.raises(TemplateNotFoundError, match="Available:"):
            loader.get_source("zzz.html")

    def test_dictloader_empty_mapping_no_available(self) -> None:
        """Empty loader produces bare 'not found' without Available."""
        loader = DictLoader({})
        with pytest.raises(TemplateNotFoundError) as exc_info:
            loader.get_source("any.html")
        assert "Available:" not in str(exc_info.value)

    # -- RuntimeError template context ---------------------------------------

    def test_not_compiled_error_includes_template_name(self) -> None:
        """'not properly compiled' RuntimeError includes the template name."""

        env = Environment()
        tmpl = env.from_string("{{ x }}", name="broken.html")
        # Forcibly clear the render func to trigger the guard
        tmpl._render_func = None
        with pytest.raises(RuntimeError, match=r"broken\.html"):
            tmpl.render(x=1)


# ---------------------------------------------------------------------------
# Error DX: Source Snippets
# ---------------------------------------------------------------------------


class TestSourceSnippets:
    """Test source snippet generation and attachment to errors."""

    def test_build_source_snippet_basic(self) -> None:
        """build_source_snippet returns correct lines and error_line."""
        source = "line1\nline2\nline3\nline4\nline5"
        snippet = build_source_snippet(source, 3)
        assert snippet.error_line == 3
        assert snippet.column is None
        # Should include lines 1-5 (2 context lines each side)
        assert len(snippet.lines) == 5
        assert snippet.lines[2] == (3, "line3")

    def test_build_source_snippet_with_column(self) -> None:
        """build_source_snippet passes column through."""
        source = "hello {{ world }}"
        snippet = build_source_snippet(source, 1, column=9)
        assert snippet.column == 9

    def test_build_source_snippet_at_start(self) -> None:
        """Snippet at line 1 doesn't go negative."""
        source = "line1\nline2\nline3"
        snippet = build_source_snippet(source, 1)
        assert snippet.lines[0][0] == 1

    def test_build_source_snippet_at_end(self) -> None:
        """Snippet at last line doesn't overflow."""
        source = "line1\nline2\nline3"
        snippet = build_source_snippet(source, 3)
        assert snippet.lines[-1][0] == 3

    def test_snippet_format_highlights_error_line(self) -> None:
        """format() marks the error line with '>'."""
        source = "line1\nline2\nline3\nline4\nline5"
        snippet = build_source_snippet(source, 3)
        formatted = snippet.format()
        plain = strip_ansi(formatted)
        assert ">  3 | line3" in plain
        assert " 2 | line2" in plain  # Non-error line has space (not '>')

    def test_snippet_format_with_column_shows_caret(self) -> None:
        """format() shows ^ pointer when column is set."""
        source = "hello {{ world }}"
        snippet = build_source_snippet(source, 1, column=9)
        formatted = snippet.format()
        assert "^" in formatted

    def test_runtime_error_has_source_snippet(self) -> None:
        """TemplateRuntimeError via _enhance_error includes source snippet."""
        env = Environment(
            loader=DictLoader(
                {
                    "test.html": "<html>\n<body>\n{% if x %}\n<p>{{ 1/y }}</p>\n{% end %}\n</body>",
                }
            )
        )
        with pytest.raises(TemplateRuntimeError) as exc_info:
            env.get_template("test.html").render(x=True, y=0)
        assert exc_info.value.source_snippet is not None
        assert exc_info.value.source_snippet.error_line > 0

    def test_undefined_error_has_source_snippet(self) -> None:
        """UndefinedError includes source snippet when line tracking is active."""
        env = Environment(
            loader=DictLoader(
                {
                    "test.html": "<html>\n{% if x %}\n<p>hi</p>\n{% end %}\n<h1>{{ missin }}</h1>",
                }
            )
        )
        with pytest.raises(UndefinedError) as exc_info:
            env.get_template("test.html").render(x=True)
        exc = exc_info.value
        # lineno may be 0 if coalescing skips line tracking,
        # but if available, snippet should be attached
        if exc.lineno:
            assert exc.source_snippet is not None

    def test_template_stores_source(self) -> None:
        """Template._source is populated after compilation."""
        env = Environment()
        t = env.from_string("Hello {{ name }}", name="test.html")
        assert t._source == "Hello {{ name }}"

    def test_template_source_from_loader(self) -> None:
        """Template._source is populated for file-based templates."""
        source = "<h1>{{ title }}</h1>"
        env = Environment(loader=DictLoader({"page.html": source}))
        t = env.get_template("page.html")
        assert t._source == source

    def test_error_in_included_template_has_source_snippet(self) -> None:
        """Error in included template shows source snippet from included file."""
        env = Environment(
            loader=DictLoader(
                {
                    "base.html": '{% include "partial.html" %}',
                    "partial.html": "Line1\nLine2\n{{ 1 / 0 }}\nLine4",
                }
            )
        )
        with pytest.raises(TemplateRuntimeError) as exc_info:
            env.get_template("base.html").render()
        exc = exc_info.value
        assert exc.source_snippet is not None
        assert exc.source_snippet.error_line == 3
        # Snippet should show the offending line from partial.html
        assert "1 / 0" in exc.source_snippet.format() or "1/0" in exc.source_snippet.format()


# ---------------------------------------------------------------------------
# Error DX: Error Codes
# ---------------------------------------------------------------------------


class TestErrorCodes:
    """Test error code assignment and properties."""

    def test_error_code_enum_values(self) -> None:
        """Error codes follow K-{CAT}-{NUM} format."""
        assert ErrorCode.UNDEFINED_VARIABLE.value == "K-RUN-001"
        assert ErrorCode.TEMPLATE_NOT_FOUND.value == "K-TPL-001"
        assert ErrorCode.SYNTAX_ERROR.value == "K-TPL-002"
        assert ErrorCode.CIRCULAR_IMPORT.value == "K-TPL-003"
        assert ErrorCode.RUNTIME_ERROR.value == "K-RUN-007"

    def test_error_code_docs_url(self) -> None:
        """docs_url returns the correct documentation link."""
        assert ErrorCode.UNDEFINED_VARIABLE.docs_url == (
            "https://lbliii.github.io/kida/docs/errors/#k-run-001"
        )

    def test_error_code_category(self) -> None:
        """category returns human-readable category name."""
        assert ErrorCode.UNDEFINED_VARIABLE.category == "runtime"
        assert ErrorCode.UNCLOSED_TAG.category == "lexer"
        assert ErrorCode.UNEXPECTED_TOKEN.category == "parser"
        assert ErrorCode.TEMPLATE_NOT_FOUND.category == "template"

    def test_undefined_error_has_code(self) -> None:
        """UndefinedError instances have the correct error code."""
        exc = UndefinedError("x")
        assert exc.code == ErrorCode.UNDEFINED_VARIABLE
        assert exc.code.value == "K-RUN-001"

    def test_runtime_error_has_code(self) -> None:
        """TemplateRuntimeError instances have the correct error code."""
        exc = TemplateRuntimeError("test")
        assert exc.code == ErrorCode.RUNTIME_ERROR

    def test_not_found_error_has_code(self) -> None:
        """TemplateNotFoundError instances have the correct error code."""
        exc = TemplateNotFoundError("missing.html")
        assert exc.code == ErrorCode.TEMPLATE_NOT_FOUND

    def test_syntax_error_has_code(self) -> None:
        """TemplateSyntaxError instances have the correct error code."""
        exc = TemplateSyntaxError("bad syntax")
        assert exc.code == ErrorCode.SYNTAX_ERROR

    def test_parse_error_invalid_identifier_has_code(self) -> None:
        """ParseError from hyphen-in-block-name has K-PAR-006."""
        from kida.parser.errors import ParseError

        env = Environment()
        with pytest.raises(ParseError) as exc_info:
            env.from_string("{% block x-y %}z{% end %}").render()
        assert exc_info.value.code == ErrorCode.INVALID_IDENTIFIER

    def test_parse_error_format_has_kida_branding_and_docs(self) -> None:
        """ParseError str() includes Kida branding and docs URL."""
        from kida.parser.errors import ParseError

        env = Environment()
        with pytest.raises(ParseError) as exc_info:
            env.from_string("{% block x-y %}z{% end %}").render()
        msg = str(exc_info.value)
        assert "Kida Parse Error" in msg
        assert "K-PAR-006" in msg
        assert "lbliii.github.io/kida/docs/errors" in msg

    def test_template_syntax_error_not_wrapped_on_render(self) -> None:
        """ParseError/TemplateSyntaxError are re-raised, not wrapped in TemplateRuntimeError."""
        env = Environment()
        with pytest.raises(TemplateSyntaxError):
            env.from_string("{% block bad-name %}x{% end %}").render()
        # Should NOT be TemplateRuntimeError wrapping ParseError


# ---------------------------------------------------------------------------
# Error DX: format_compact()
# ---------------------------------------------------------------------------


class TestFormatCompact:
    """Test format_compact() output on all exception types."""

    def test_undefined_format_compact(self) -> None:
        """UndefinedError.format_compact() includes code, message, hint, docs."""
        exc = UndefinedError("usernme", "page.html", 42, frozenset({"username", "email"}))
        compact = exc.format_compact()
        plain = strip_ansi(compact)
        assert "K-RUN-001" in plain
        assert "usernme" in plain
        assert "page.html:42" in plain
        assert "Did you mean 'username'?" in plain
        assert "default('')" in plain
        assert "lbliii.github.io/kida/docs/errors" in plain

    def test_undefined_format_compact_with_snippet(self) -> None:
        """UndefinedError.format_compact() includes source snippet."""
        snippet = build_source_snippet("<h1>{{ usernme }}</h1>", 1)
        exc = UndefinedError(
            "usernme",
            "page.html",
            1,
            frozenset({"username"}),
            source_snippet=snippet,
        )
        compact = exc.format_compact()
        assert "{{ usernme }}" in compact

    def test_runtime_error_format_compact(self) -> None:
        """TemplateRuntimeError.format_compact() includes code, location, docs."""
        exc = TemplateRuntimeError(
            "division by zero",
            template_name="calc.html",
            lineno=10,
        )
        compact = exc.format_compact()
        assert "K-RUN-007" in compact
        assert "division by zero" in compact
        assert "calc.html:10" in compact
        assert "lbliii.github.io/kida/docs/errors" in compact

    def test_runtime_error_format_compact_with_snippet(self) -> None:
        """TemplateRuntimeError.format_compact() includes source snippet."""
        snippet = build_source_snippet("<p>{{ 1 / x }}</p>", 1)
        exc = TemplateRuntimeError(
            "division by zero",
            template_name="calc.html",
            lineno=1,
            source_snippet=snippet,
        )
        compact = exc.format_compact()
        assert "1 / x" in compact

    def test_syntax_error_format_compact(self) -> None:
        """TemplateSyntaxError.format_compact() includes code and location."""
        exc = TemplateSyntaxError(
            "Unexpected token",
            lineno=5,
            filename="broken.html",
            source="line1\nline2\nline3\nline4\n{{ bad }",
            col_offset=6,
        )
        compact = exc.format_compact()
        assert "K-TPL-002" in compact
        assert "broken.html:5" in compact
        assert "^" in compact
        assert "lbliii.github.io/kida/docs/errors" in compact

    def test_not_found_format_compact(self) -> None:
        """TemplateNotFoundError.format_compact() includes code and docs."""
        exc = TemplateNotFoundError("missing.html")
        compact = exc.format_compact()
        assert "K-TPL-001" in compact
        assert "lbliii.github.io/kida/docs/errors" in compact

    def test_base_template_error_format_compact(self) -> None:
        """Base TemplateError.format_compact() works without code."""
        from kida.environment.exceptions import TemplateError

        exc = TemplateError("something went wrong")
        compact = exc.format_compact()
        assert "something went wrong" in compact
