"""Tests for Kida resilient None handling.

RFC: rfc-kida-resilient-error-handling.md

This module tests the None → "" behavior that makes Kida templates
more resilient to missing data, similar to Hugo/Go templates.

Key behaviors:
1. Accessing attribute on None returns ""
2. Accessing None attribute value returns ""
3. Chained access through None returns ""
4. Errors include template name and line number context
"""

from __future__ import annotations

import pytest

from kida.environment import Environment
from kida.environment.exceptions import (
    TemplateRuntimeError,
)


@pytest.fixture
def env() -> Environment:
    """Create a fresh Kida environment for each test.

    Tests lenient None handling behavior.

    """
    return Environment()


class TestResilientNoneHandling:
    """Test None → '' behavior (like Hugo/Go templates)."""

    def test_none_attribute_returns_empty(self, env: Environment) -> None:
        """Accessing an attribute that is None returns empty string."""
        tmpl = env.from_string("{{ obj.missing }}")
        result = tmpl.render(obj={"present": "value"})
        assert result == ""

    def test_default_filter_with_undefined_missing_key(self, env: Environment) -> None:
        """default() treats UNDEFINED (missing attribute/key) as missing."""
        tmpl = env.from_string("{{ obj.missing | default('fallback') }}")
        result = tmpl.render(obj={"present": "value"})
        assert result == "fallback"

    def test_none_object_access_returns_empty(self, env: Environment) -> None:
        """Accessing attribute on None object returns empty string."""
        tmpl = env.from_string("{{ obj.attr }}")
        result = tmpl.render(obj=None)
        assert result == ""

    def test_none_value_returns_empty(self, env: Environment) -> None:
        """Attribute with explicit None value returns empty string."""
        tmpl = env.from_string("{{ obj.value }}")
        result = tmpl.render(obj={"value": None})
        assert result == ""

    def test_none_chain_returns_empty(self, env: Environment) -> None:
        """Chained access through None returns empty string."""
        tmpl = env.from_string("{{ obj.a.b.c }}")
        result = tmpl.render(obj={"a": None})
        assert result == ""

    def test_deep_none_chain_returns_empty(self, env: Environment) -> None:
        """Deep chained access where middle is None returns empty."""
        tmpl = env.from_string("{{ page.metadata.author.name }}")
        result = tmpl.render(page={"metadata": {"author": None}})
        assert result == ""

    def test_none_in_conditional_is_falsy(self, env: Environment) -> None:
        """None (now "") is falsy in conditionals."""
        tmpl = env.from_string("{% if obj.value %}yes{% else %}no{% end %}")
        result = tmpl.render(obj={"value": None})
        assert result == "no"

    def test_not_none_check_still_falsy(self, env: Environment) -> None:
        """Empty string from None is still falsy for 'not x' check."""
        tmpl = env.from_string("{% if not obj.value %}empty{% else %}has value{% end %}")
        result = tmpl.render(obj={"value": None})
        assert result == "empty"

    def test_present_value_renders(self, env: Environment) -> None:
        """Non-None values still render correctly."""
        tmpl = env.from_string("{{ obj.title }}")
        result = tmpl.render(obj={"title": "Hello World"})
        assert result == "Hello World"

    def test_zero_value_not_converted(self, env: Environment) -> None:
        """Zero values (0, 0.0) are NOT converted to empty string."""
        tmpl = env.from_string("{{ obj.count }}")
        result = tmpl.render(obj={"count": 0})
        assert result == "0"

    def test_false_value_not_converted(self, env: Environment) -> None:
        """False is NOT converted to empty string."""
        tmpl = env.from_string("{{ obj.flag }}")
        result = tmpl.render(obj={"flag": False})
        assert result == "False"

    def test_empty_string_preserved(self, env: Environment) -> None:
        """Empty string values are preserved as empty string."""
        tmpl = env.from_string("[{{ obj.value }}]")
        result = tmpl.render(obj={"value": ""})
        assert result == "[]"


class TestNoneInFilters:
    """Test None handling in filter operations."""

    def test_default_filter_with_none_value(self, env: Environment) -> None:
        """Default filter provides fallback for None values.

        Note: With None→"" behavior, default() sees "" not None.
        But "" is still falsy so default() with false=True works.
        """
        # default() without test_false doesn't trigger on ""
        tmpl = env.from_string("{{ obj.value | default('fallback', true) }}")
        result = tmpl.render(obj={"value": None})
        assert result == "fallback"

    def test_filter_chain_with_none(self, env: Environment) -> None:
        """Filter chains work when starting value is None.

        With None→"" normalization, obj.name becomes "".
        default('Unknown') only triggers on falsy if test_false=True.
        So we need default('Unknown', true) to get the fallback.
        """
        tmpl = env.from_string("{{ obj.name | default('Unknown', true) | upper }}")
        result = tmpl.render(obj={"name": None})
        assert result == "UNKNOWN"

    def test_join_filter_with_none_items(self, env: Environment) -> None:
        """Join filter handles lists containing None.

        Note: None→"" normalization only applies to attribute access,
        not to raw list values. So None in a list is still "None" when joined.
        """
        tmpl = env.from_string("{{ items | join(', ') }}")
        result = tmpl.render(items=["a", None, "b"])
        # Raw list values are not normalized - join sees actual None
        assert result == "a, None, b"

    def test_length_of_none_attr(self, env: Environment) -> None:
        """Length of None attribute (now "") is 0."""
        tmpl = env.from_string("{{ obj.items | default([]) | length }}")
        result = tmpl.render(obj={"items": None})
        assert result == "0"


class TestSortWithNone:
    """Test sort filter handles None gracefully."""

    def test_sort_with_none_values_places_none_last(self, env: Environment) -> None:
        """Sort places None values last."""
        tmpl = env.from_string(
            "{{ items | sort(attribute='weight') | map(attribute='name') | join(',') }}"
        )
        items = [
            {"name": "b", "weight": 2},
            {"name": "a", "weight": None},
            {"name": "c", "weight": 1},
        ]
        result = tmpl.render(items=items)
        # None should sort last: c(1), b(2), a(None)
        assert result == "c,b,a"

    def test_sort_all_none_values(self, env: Environment) -> None:
        """Sort with all None values doesn't crash."""
        tmpl = env.from_string("{{ items | sort(attribute='weight') | length }}")
        items = [
            {"name": "a", "weight": None},
            {"name": "b", "weight": None},
        ]
        result = tmpl.render(items=items)
        assert result == "2"

    def test_sort_mixed_types_with_none(self, env: Environment) -> None:
        """Sort handles mixed numeric/None values."""
        tmpl = env.from_string(
            "{{ items | sort(attribute='order') | map(attribute='id') | join(',') }}"
        )
        items = [
            {"id": "third", "order": 3},
            {"id": "none", "order": None},
            {"id": "first", "order": 1},
        ]
        result = tmpl.render(items=items)
        assert result == "first,third,none"

    def test_sort_no_attribute_with_none(self, env: Environment) -> None:
        """Sort without attribute handles None in list.

        The sort filter handles None gracefully (sorts last).
        """
        tmpl = env.from_string("{{ items | sort | join(',') }}")
        items = [3, None, 1, 2]
        result = tmpl.render(items=items)
        # None sorts last and becomes "None" when stringified
        assert result == "1,2,3,None"


class TestErrorLineNumbers:
    """Test error messages include template name and line numbers."""

    def test_error_includes_template_name(self, env: Environment) -> None:
        """Errors include template name when provided."""
        tmpl = env.from_string("{{ 1 / 0 }}", name="test.html")
        with pytest.raises(TemplateRuntimeError) as exc:
            tmpl.render()
        assert "test.html" in str(exc.value)

    def test_error_includes_line_number_single_line(self, env: Environment) -> None:
        """Errors include line number for single-line templates."""
        tmpl = env.from_string("{{ 1 / 0 }}", name="test.html")
        with pytest.raises(TemplateRuntimeError) as exc:
            tmpl.render()
        # Line 1 for single-line template
        error_str = str(exc.value)
        assert "test.html" in error_str
        # Should have :1 or "line 1" somewhere
        assert ":1" in error_str or "line 1" in error_str.lower()

    def test_error_includes_line_number_multi_line(self, env: Environment) -> None:
        """Errors include line number for multi-line templates."""
        template_src = """Line 1
Line 2
{{ 1 / 0 }}
Line 4"""
        tmpl = env.from_string(template_src, name="test.html")
        with pytest.raises(TemplateRuntimeError) as exc:
            tmpl.render()
        error_str = str(exc.value)
        assert "test.html" in error_str
        # The error is on line 3
        assert ":3" in error_str or "line 3" in error_str.lower()

    def test_none_comparison_error_has_context(self, env: Environment) -> None:
        """NoneComparisonError includes template context."""
        # This test uses a scenario that would cause comparison error
        # With the new None-safe sort, this shouldn't raise anymore
        # So we skip this test - the sort is now resilient
        pytest.skip("Sort is now None-resilient, won't raise NoneComparisonError")

    def test_type_error_enhanced_with_context(self, env: Environment) -> None:
        """Generic TypeError is enhanced with template context."""
        # Force a TypeError: range() requires int, not str
        tmpl = env.from_string("{{ range(x)|list }}", name="type_error.html")
        with pytest.raises(TemplateRuntimeError) as exc:
            tmpl.render(x="not_an_int")
        error_str = str(exc.value)
        assert "type_error.html" in error_str


class TestMigrationPatterns:
    """Test migration patterns from old to new behavior."""

    def test_is_none_check_now_false(self, env: Environment) -> None:
        """is none check returns False since None becomes ''.

        Migration: Replace `{% if x.y is none %}` with `{% if not x.y %}`
        """
        # This is a BREAKING CHANGE test - documents the behavior change
        tmpl = env.from_string("{% if obj.value is none %}yes{% else %}no{% end %}")
        result = tmpl.render(obj={"value": None})
        # With None→"" normalization, value is "" not None, so `is none` is False
        assert result == "no"

    def test_not_x_pattern_works(self, env: Environment) -> None:
        """not x pattern works for checking None/empty."""
        tmpl = env.from_string("{% if not obj.value %}empty{% else %}has{% end %}")
        result = tmpl.render(obj={"value": None})
        assert result == "empty"

    def test_or_default_pattern_works(self, env: Environment) -> None:
        """x or default pattern provides fallback."""
        tmpl = env.from_string("{{ obj.weight or 999999 }}")
        result = tmpl.render(obj={"weight": None})
        assert result == "999999"

    def test_default_filter_with_boolean_false(self, env: Environment) -> None:
        """default(x, true) treats empty string as missing."""
        tmpl = env.from_string("{{ obj.value | default('fallback', true) }}")
        # With None→"", default sees "", and with true flag treats it as missing
        result = tmpl.render(obj={"value": None})
        assert result == "fallback"


class TestDictSafeAttributeResolution:
    """Test that dict keys take precedence over dict methods.

    When a template accesses {{ section.items }}, it should resolve to
    section["items"] (user data), not dict.items (the method). This
    prevents common footguns with dict method names as keys.
    """

    def test_dict_items_key_over_method(self, env: Environment) -> None:
        """{{ d.items }} resolves to d["items"], not dict.items method."""
        tmpl = env.from_string("{% for x in d.items %}{{ x }} {% end %}")
        result = tmpl.render(d={"items": ["a", "b", "c"]})
        assert "a" in result
        assert "b" in result
        assert "c" in result

    def test_dict_keys_key_over_method(self, env: Environment) -> None:
        """{{ d.keys }} resolves to d["keys"], not dict.keys method."""
        tmpl = env.from_string("{{ d.keys }}")
        result = tmpl.render(d={"keys": "my-keys-value"})
        assert result == "my-keys-value"

    def test_dict_values_key_over_method(self, env: Environment) -> None:
        """{{ d.values }} resolves to d["values"], not dict.values method."""
        tmpl = env.from_string("{{ d.values }}")
        result = tmpl.render(d={"values": "my-values"})
        assert result == "my-values"

    def test_dict_get_key_over_method(self, env: Environment) -> None:
        """{{ d.get }} resolves to d["get"], not dict.get method."""
        tmpl = env.from_string("{{ d.get }}")
        result = tmpl.render(d={"get": "gotten"})
        assert result == "gotten"

    def test_dict_update_key_over_method(self, env: Environment) -> None:
        """{{ d.update }} resolves to d["update"], not dict.update method."""
        tmpl = env.from_string("{{ d.update }}")
        result = tmpl.render(d={"update": "updated"})
        assert result == "updated"

    def test_dict_normal_key_still_works(self, env: Environment) -> None:
        """Regular dict keys still resolve correctly."""
        tmpl = env.from_string("{{ d.name }}: {{ d.value }}")
        result = tmpl.render(d={"name": "Alice", "value": 42})
        assert result == "Alice: 42"

    def test_dict_missing_key_falls_back_to_getattr(self, env: Environment) -> None:
        """Missing dict key falls back to getattr (e.g. dict methods)."""
        from kida.template.helpers import safe_getattr

        # Calling dict.items() explicitly should still work
        result = safe_getattr({"a": 1}, "items")
        # items is not a key, so falls back to getattr → dict.items method
        assert callable(result)

    def test_object_attr_resolution_unchanged(self, env: Environment) -> None:
        """Non-dict objects still use getattr-first resolution."""
        from kida.template.helpers import safe_getattr

        class Obj:
            name = "Alice"

        result = safe_getattr(Obj(), "name")
        assert result == "Alice"

    def test_optional_chaining_dict_safe(self, env: Environment) -> None:
        """Optional chaining (?.) also uses dict-safe resolution."""
        from kida.template.helpers import getattr_preserve_none

        result = getattr_preserve_none({"items": [1, 2]}, "items")
        assert result == [1, 2]

    def test_dict_nested_access(self, env: Environment) -> None:
        """Nested dict access works with dict-safe resolution."""
        tmpl = env.from_string("{{ d.section.items }}")
        result = tmpl.render(d={"section": {"items": "nested-value"}})
        assert result == "nested-value"


class TestNullCoalesceAndDefaultWithUndefined:
    """Test ?? and | default() handle _Undefined from failed attribute access."""

    def test_null_coalesce_on_missing_attribute(self, env: Environment) -> None:
        """obj.missing ?? 'fb' returns 'fb' when attribute is missing."""
        tmpl = env.from_string("{{ obj.missing ?? 'fb' }}")
        result = tmpl.render(obj={"present": "value"})
        assert result == "fb"

    def test_default_filter_on_missing_attribute(self, env: Environment) -> None:
        """obj.missing | default('fb') returns 'fb' when attribute is missing."""
        tmpl = env.from_string("{{ obj.missing | default('fb') }}")
        result = tmpl.render(obj={"present": "value"})
        assert result == "fb"

    def test_null_coalesce_preserves_falsy(self, env: Environment) -> None:
        """obj.zero ?? 'fb' returns 0 (not the fallback)."""
        tmpl = env.from_string("{{ obj.zero ?? 'fb' }}")
        result = tmpl.render(obj={"zero": 0})
        assert result == "0"

    def test_default_preserves_falsy_without_boolean(self, env: Environment) -> None:
        """obj.empty_str | default('fb') returns '' when value is empty string."""
        tmpl = env.from_string("{{ obj.empty_str | default('fb') }}")
        result = tmpl.render(obj={"empty_str": ""})
        assert result == ""

    def test_chained_optional_with_coalesce(self, env: Environment) -> None:
        """obj?.a?.b ?? 'fb' returns 'fb' when chain fails."""
        tmpl = env.from_string("{{ obj?.a?.b ?? 'fb' }}")
        result = tmpl.render(obj={"a": None})
        assert result == "fb"


class TestUndefinedMethodCalls:
    """Test _Undefined in method calls and filter chains."""

    def test_undefined_get_returns_default(self, env: Environment) -> None:
        """obj.missing.get('key', 'default') returns 'default' when attribute is missing."""
        tmpl = env.from_string("{{ obj.missing.get('key', 'default') }}")
        result = tmpl.render(obj={"present": "value"})
        assert result == "default"

    def test_undefined_method_call_get(self, env: Environment) -> None:
        """obj.missing.get('key') returns '' when no default given (_Undefined.get returns None)."""
        tmpl = env.from_string("{{ obj.missing.get('key') }}")
        result = tmpl.render(obj={"present": "value"})
        assert result == ""

    def test_undefined_method_call_items(self, env: Environment) -> None:
        """{% for k, v in obj.missing.items() %} yields nothing (no error)."""
        tmpl = env.from_string("{% for k, v in obj.missing.items() %}x{% end %}")
        result = tmpl.render(obj={"present": "value"})
        assert result == ""

    def test_undefined_keys_returns_empty(self, env: Environment) -> None:
        """obj.missing.keys() returns empty list; for loop yields nothing."""
        tmpl = env.from_string("{% for k in obj.missing.keys() %}{{ k }}{% end %}")
        result = tmpl.render(obj={"present": "value"})
        assert result == ""

    def test_undefined_values_returns_empty(self, env: Environment) -> None:
        """obj.missing.values() returns empty list; for loop yields nothing."""
        tmpl = env.from_string("{% for v in obj.missing.values() %}{{ v }}{% end %}")
        result = tmpl.render(obj={"present": "value"})
        assert result == ""

    def test_undefined_method_call_upper(self, env: Environment) -> None:
        """obj.missing | upper produces '' or clear error."""
        tmpl = env.from_string("{{ obj.missing | upper }}")
        result = tmpl.render(obj={"present": "value"})
        assert result == ""

    def test_undefined_in_filter_chain(self, env: Environment) -> None:
        """obj.missing | default('x') | upper returns 'X'."""
        tmpl = env.from_string("{{ obj.missing | default('x') | upper }}")
        result = tmpl.render(obj={"present": "value"})
        assert result == "X"

    def test_optional_method_call_none_short_circuits(self, env: Environment) -> None:
        """obj?.missing() when obj is None yields '' (short-circuits)."""
        tmpl = env.from_string("{{ obj?.missing() }}")
        result = tmpl.render(obj=None)
        assert result == ""

    def test_optional_method_call_undefined_short_circuits(self, env: Environment) -> None:
        """obj?.missing() when obj.missing is _Undefined yields '' (short-circuits)."""
        tmpl = env.from_string("{{ obj?.missing() }}")
        result = tmpl.render(obj={"x": 1})
        assert result == ""

    def test_optional_method_call_valid_works(self, env: Environment) -> None:
        """obj?.upper() when obj exists calls the method."""
        tmpl = env.from_string("{{ obj?.upper() }}")
        result = tmpl.render(obj="hello")
        assert result == "HELLO"


class TestPerformanceCharacteristics:
    """Test that performance characteristics are maintained."""

    def test_safe_getattr_is_o1(self, env: Environment) -> None:
        """Verify _safe_getattr maintains O(1) complexity.

        This is a sanity check - the actual benchmarks are in Phase 3.
        """
        from kida.template.helpers import safe_getattr

        # Single access should be fast
        obj = {"key": "value"}
        result = safe_getattr(obj, "key")
        assert result == "value"

        # None access should return UNDEFINED sentinel (stringifies as "")
        result = safe_getattr(None, "key")
        assert str(result) == ""
        assert not result  # falsy

        # Missing key should return UNDEFINED sentinel (stringifies as "")
        result = safe_getattr(obj, "missing")
        assert str(result) == ""
        assert not result  # falsy
