"""Unit tests for the typography module (Font, ThemeFontEntry, Theme font methods)."""

from dataclasses import FrozenInstanceError

import pytest

from armonia.exceptions import (
    FontNameConflictError,
    FontNotFoundError,
    FontRecursionError,
)
from armonia.theme import Theme
from armonia.typography import Font, ThemeFontEntry


class TestFontCreation:
    """Tests for Font construction and validation."""

    def test_valid_font(self) -> None:
        """Test creating a valid font."""
        font = Font("Inter", 16.0, 400, False)
        assert font.family == "Inter"
        assert font.size == 16.0
        assert font.weight == 400
        assert font.italic is False

    def test_font_is_frozen(self) -> None:
        """Test that Font is immutable."""
        font = Font("Inter", 16.0, 400, False)
        with pytest.raises(FrozenInstanceError):
            font.size = 20.0  # type: ignore[misc]

    @pytest.mark.parametrize("size", [0.0, -1.0, -100.0])
    def test_invalid_size_raises(self, size: float) -> None:
        """Test that non-positive sizes raise ValueError."""
        with pytest.raises(ValueError, match="size"):
            Font("Inter", size, 400, False)

    @pytest.mark.parametrize("weight", [0, -1, 1001, 2000])
    def test_invalid_weight_raises(self, weight: int) -> None:
        """Test that out-of-range weights raise ValueError."""
        with pytest.raises(ValueError, match="weight"):
            Font("Inter", 16.0, weight, False)

    @pytest.mark.parametrize("weight", [1, 1000])
    def test_boundary_weights_are_valid(self, weight: int) -> None:
        """Test that boundary weight values 1 and 1000 are valid."""
        font = Font("Inter", 16.0, weight, False)
        assert font.weight == weight

    def test_italic_true(self) -> None:
        """Test creating a font with italic enabled."""
        font = Font("Georgia", 14.0, 400, True)
        assert font.italic is True


class TestFontVariant:
    """Tests for the Font.variant method."""

    def test_variant_changes_size(self) -> None:
        """Test that variant can change the font size."""
        font = Font("Inter", 16.0, 400, False)
        larger = font.variant(size=24.0)
        assert larger.size == 24.0
        assert larger.family == font.family
        assert larger.weight == font.weight
        assert larger.italic == font.italic

    def test_variant_changes_weight(self) -> None:
        """Test that variant can change the font weight."""
        font = Font("Inter", 16.0, 400, False)
        bold = font.variant(weight=700)
        assert bold.weight == 700
        assert bold.family == font.family
        assert bold.size == font.size
        assert bold.italic == font.italic

    def test_variant_changes_italic(self) -> None:
        """Test that variant can change the italic flag."""
        font = Font("Inter", 16.0, 400, False)
        italic = font.variant(italic=True)
        assert italic.italic is True
        assert italic.family == font.family
        assert italic.size == font.size
        assert italic.weight == font.weight

    def test_variant_changes_multiple_properties(self) -> None:
        """Test that variant can change multiple properties at once."""
        font = Font("Inter", 16.0, 400, False)
        derived = font.variant(size=20.0, weight=700, italic=True)
        assert derived.size == 20.0
        assert derived.weight == 700
        assert derived.italic is True

    def test_variant_preserves_family(self) -> None:
        """Test that variant always preserves the font family."""
        font = Font("Inter", 16.0, 400, False)
        derived = font.variant(size=32.0, weight=900, italic=True)
        assert derived.family == "Inter"

    def test_variant_with_no_args_returns_equal_font(self) -> None:
        """Test that calling variant with no arguments returns an equal font."""
        font = Font("Inter", 16.0, 400, False)
        copy = font.variant()
        assert copy == font

    def test_variant_propagates_validation(self) -> None:
        """Test that variant raises ValueError for invalid resulting values."""
        font = Font("Inter", 16.0, 400, False)
        with pytest.raises(ValueError, match="size"):
            font.variant(size=0.0)

    def test_variant_returns_new_instance(self) -> None:
        """Test that variant returns a new Font instance, not the same one."""
        font = Font("Inter", 16.0, 400, False)
        derived = font.variant(weight=700)
        assert derived is not font


class TestThemeFontEntry:
    """Tests for the ThemeFontEntry dataclass."""

    def test_theme_font_entry_manual(self) -> None:
        """Test creating a manual ThemeFontEntry."""
        font = Font("Inter", 16.0, 400, False)
        entry = ThemeFontEntry(name="body", font=font, source="manual")
        assert entry.name == "body"
        assert entry.font == font
        assert entry.source == "manual"

    def test_theme_font_entry_computed(self) -> None:
        """Test creating a computed ThemeFontEntry."""
        font = Font("Inter", 20.0, 700, False)
        entry = ThemeFontEntry(name="heading", font=font, source="computed")
        assert entry.source == "computed"

    def test_theme_font_entry_is_frozen(self) -> None:
        """Test that ThemeFontEntry is immutable."""
        font = Font("Inter", 16.0, 400, False)
        entry = ThemeFontEntry(name="body", font=font, source="manual")
        with pytest.raises(FrozenInstanceError):
            entry.name = "other"  # type: ignore[misc]


class TestSetFont:
    """Tests for the Theme.set_font method."""

    def test_set_single_font(self) -> None:
        """Test setting a single font."""
        theme = Theme()
        font = Font("Inter", 16.0, 400, False)
        theme.set_font("body", font)
        assert "body" in theme.fonts
        assert theme.fonts["body"] == font

    def test_set_multiple_fonts(self) -> None:
        """Test setting multiple fonts."""
        theme = Theme()
        body = Font("Inter", 16.0, 400, False)
        heading = Font("Georgia", 24.0, 700, False)
        theme.set_font("body", body)
        theme.set_font("heading", heading)
        assert len(theme.fonts) == 2

    def test_set_font_overwrites_existing(self) -> None:
        """Test that setting a font with an existing name overwrites it."""
        theme = Theme()
        old = Font("Inter", 16.0, 400, False)
        new = Font("Roboto", 18.0, 400, False)
        theme.set_font("body", old)
        theme.set_font("body", new)
        assert theme.fonts["body"] == new

    def test_set_font_conflicts_with_computed_font_raises(self) -> None:
        """Test that set_font raises when name is already a computed font."""
        theme = Theme()
        theme.set_computed_font("bold", lambda _: Font("Inter", 16.0, 700, False))
        with pytest.raises(FontNameConflictError):
            theme.set_font("bold", Font("Inter", 16.0, 400, False))


class TestSetComputedFont:
    """Tests for the Theme.set_computed_font method."""

    def test_set_computed_font(self) -> None:
        """Test setting a computed font."""
        theme = Theme()

        def compute(t: Theme) -> Font:
            return Font("Inter", 20.0, 700, False)

        theme.set_computed_font("heading", compute)
        assert "heading" in theme.computed_fonts

    def test_set_computed_font_conflicts_with_manual_font_raises(self) -> None:
        """Test that set_computed_font raises when name is already a manual font."""
        theme = Theme()
        theme.set_font("body", Font("Inter", 16.0, 400, False))
        with pytest.raises(FontNameConflictError):
            theme.set_computed_font("body", lambda _: Font("Inter", 20.0, 700, False))

    def test_computed_font_receives_theme(self) -> None:
        """Test that a computed font function receives the theme instance."""
        theme = Theme()
        received: list[Theme] = []

        def compute(t: Theme) -> Font:
            received.append(t)
            return Font("Inter", 16.0, 400, False)

        theme.set_computed_font("body", compute)
        theme.get_font("body")
        assert received[0] is theme


class TestGetFont:
    """Tests for the Theme.get_font method."""

    def test_get_manual_font(self) -> None:
        """Test retrieving a manually set font."""
        theme = Theme()
        font = Font("Inter", 16.0, 400, False)
        theme.set_font("body", font)
        assert theme.get_font("body") == font

    def test_get_computed_font(self) -> None:
        """Test retrieving a computed font."""
        theme = Theme()
        font = Font("Inter", 16.0, 400, False)
        theme.set_font("body", font)
        theme.set_computed_font("bold", lambda t: t.get_font("body").variant(weight=700))
        result = theme.get_font("bold")
        assert result.weight == 700
        assert result.family == "Inter"

    def test_get_font_not_found_raises(self) -> None:
        """Test that FontNotFoundError is raised when font does not exist."""
        theme = Theme()
        with pytest.raises(FontNotFoundError, match="body"):
            theme.get_font("body")

    def test_get_font_recursion_raises(self) -> None:
        """Test that FontRecursionError is raised on circular computed font dependency."""
        theme = Theme()
        theme.set_computed_font("a", lambda t: t.get_font("b"))
        theme.set_computed_font("b", lambda t: t.get_font("a"))
        with pytest.raises(FontRecursionError):
            theme.get_font("a")


class TestRemoveFont:
    """Tests for the Theme.remove_font method."""

    def test_remove_existing_font(self) -> None:
        """Test removing an existing manual font."""
        theme = Theme()
        theme.set_font("body", Font("Inter", 16.0, 400, False))
        theme.remove_font("body")
        assert "body" not in theme.fonts

    def test_remove_nonexistent_font_raises_key_error(self) -> None:
        """Test that removing a nonexistent font raises KeyError."""
        theme = Theme()
        with pytest.raises(KeyError):
            theme.remove_font("nonexistent")

    def test_remove_font_does_not_affect_computed_fonts(self) -> None:
        """Test that removing a manual font does not affect computed fonts."""
        theme = Theme()
        theme.set_font("body", Font("Inter", 16.0, 400, False))
        theme.set_computed_font("heading", lambda _: Font("Georgia", 24.0, 700, False))
        theme.remove_font("body")
        assert "heading" in theme.computed_fonts


class TestRemoveComputedFont:
    """Tests for the Theme.remove_computed_font method."""

    def test_remove_computed_font(self) -> None:
        """Test removing an existing computed font."""
        theme = Theme()
        theme.set_computed_font("heading", lambda _: Font("Georgia", 24.0, 700, False))
        theme.remove_computed_font("heading")
        assert "heading" not in theme.computed_fonts

    def test_remove_nonexistent_computed_font_raises_key_error(self) -> None:
        """Test that removing a nonexistent computed font raises KeyError."""
        theme = Theme()
        with pytest.raises(KeyError):
            theme.remove_computed_font("nonexistent")


class TestGetAllFonts:
    """Tests for the Theme.get_all_fonts method."""

    def test_get_all_fonts_empty(self) -> None:
        """Test that get_all_fonts returns an empty list when no fonts are set."""
        theme = Theme()
        assert theme.get_all_fonts() == []

    def test_get_all_fonts_includes_manual_and_computed(self) -> None:
        """Test that get_all_fonts includes both manual and computed fonts."""
        theme = Theme()
        theme.set_font("body", Font("Inter", 16.0, 400, False))
        theme.set_computed_font("bold", lambda t: t.get_font("body").variant(weight=700))
        entries = theme.get_all_fonts()
        sources = {e.source for e in entries}
        assert "manual" in sources
        assert "computed" in sources

    @pytest.mark.parametrize(("sort_by", "key"), [
        ("name", lambda e: e.name),
        ("family", lambda e: e.font.family),
        ("size", lambda e: e.font.size),
        ("weight", lambda e: e.font.weight),
    ])
    def test_get_all_fonts_sort_keys(self, sort_by: str, key: object) -> None:
        """Test that get_all_fonts sorts correctly by each available key."""
        theme = Theme()
        theme.set_font("z_body", Font("Roboto", 14.0, 300, False))
        theme.set_font("a_heading", Font("Georgia", 24.0, 700, False))
        theme.set_font("m_caption", Font("Arial", 12.0, 400, False))
        entries = theme.get_all_fonts(sort_by=sort_by)  # type: ignore[arg-type]
        values = [key(e) for e in entries]  # type: ignore[operator]
        assert values == sorted(values)

    def test_get_all_fonts_reverse(self) -> None:
        """Test that reverse=True reverses the sort order."""
        theme = Theme()
        theme.set_font("a", Font("Arial", 12.0, 400, False))
        theme.set_font("z", Font("Zapf", 24.0, 700, False))
        entries = theme.get_all_fonts(sort_by="name", reverse=True)
        assert entries[0].name == "z"
        assert entries[1].name == "a"

    def test_get_all_fonts_skips_broken_computed(self) -> None:
        """Test that get_all_fonts silently skips computed fonts that fail to resolve."""
        theme = Theme()
        theme.set_font("body", Font("Inter", 16.0, 400, False))
        theme.set_computed_font("broken", lambda t: t.get_font("nonexistent"))
        entries = theme.get_all_fonts()
        names = [e.name for e in entries]
        assert "body" in names
        assert "broken" not in names

    def test_get_all_fonts_entry_sources(self) -> None:
        """Test that entries report the correct source (manual or computed)."""
        theme = Theme()
        theme.set_font("body", Font("Inter", 16.0, 400, False))
        theme.set_computed_font("bold", lambda t: t.get_font("body").variant(weight=700))
        entries = {e.name: e for e in theme.get_all_fonts()}
        assert entries["body"].source == "manual"
        assert entries["bold"].source == "computed"
