# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""Tests for the creature avatar system."""

import json

from familiar.creature.palette import (
    PALETTE,
    PALETTE_ORDER,
    adjust_brightness,
    ansi_fg,
    ansi_reset,
    build_color_map,
    compute_highlight,
    compute_shadow,
    hex_to_rgb,
    resolve_color,
    rgb_to_hex,
)
from familiar.creature.renderer import render_frame, render_frame_plain
from familiar.creature.species import (
    EVOLUTION_FLAVOR,
    SPECIES_INFO,
    SPECIES_ORDER,
    SPECIES_VOICE_DIRECTIVES,
    Species,
    get_species_voice,
)
from familiar.creature.sprites import EGG_FRAMES, SPRITES, get_sprite_frames
from familiar.creature.state import (
    ActivityState,
    CreatureState,
    EvolutionStage,
    create_creature,
    load_creature,
    save_creature,
)

# ── State persistence tests ─────────────────────────────────────────


class TestCreatureState:
    def test_default_state(self):
        state = CreatureState()
        assert state.name == ""
        assert state.species == "fox"
        assert state.evolution_stage == "baby"
        assert state.total_interactions == 0

    def test_custom_state(self):
        state = CreatureState(
            name="Kitsune",
            species="fox",
            color_body="#FF0000",
            color_eyes="#00FF00",
            color_accent="#0000FF",
        )
        assert state.name == "Kitsune"
        assert state.color_body == "#FF0000"

    def test_save_and_load(self, tmp_path):
        creature = create_creature(
            name="TestFox",
            species="fox",
            color_body="#AA0000",
            color_eyes="#00AA00",
            color_accent="#0000AA",
            data_dir=tmp_path,
        )
        assert creature.name == "TestFox"
        assert creature.born_at != ""

        loaded = load_creature(data_dir=tmp_path)
        assert loaded is not None
        assert loaded.name == "TestFox"
        assert loaded.species == "fox"
        assert loaded.color_body == "#AA0000"
        assert loaded.evolution_stage == "baby"

    def test_load_nonexistent(self, tmp_path):
        result = load_creature(data_dir=tmp_path)
        assert result is None

    def test_save_overwrites(self, tmp_path):
        creature = create_creature(name="First", data_dir=tmp_path)
        assert load_creature(data_dir=tmp_path).name == "First"

        creature.name = "Second"
        save_creature(creature, data_dir=tmp_path)
        assert load_creature(data_dir=tmp_path).name == "Second"

    def test_load_corrupted_json(self, tmp_path):
        path = tmp_path / "creature.json"
        path.write_text("not valid json{{{")
        result = load_creature(data_dir=tmp_path)
        assert result is None

    def test_load_ignores_unknown_fields(self, tmp_path):
        path = tmp_path / "creature.json"
        data = {"name": "Test", "species": "fox", "unknown_field": "value"}
        path.write_text(json.dumps(data))
        result = load_creature(data_dir=tmp_path)
        assert result is not None
        assert result.name == "Test"

    def test_atomic_write(self, tmp_path):
        """Verify the temp file is cleaned up after save."""
        create_creature(name="Atomic", data_dir=tmp_path)
        assert (tmp_path / "creature.json").exists()
        assert not (tmp_path / "creature.json.tmp").exists()


class TestEnums:
    def test_evolution_stages(self):
        assert EvolutionStage.BABY.value == "baby"
        assert EvolutionStage.JUVENILE.value == "juvenile"
        assert EvolutionStage.ADULT.value == "adult"

    def test_activity_states(self):
        assert ActivityState.IDLE.value == "idle"
        assert ActivityState.THINKING.value == "thinking"
        assert ActivityState.WORKING.value == "working"
        assert ActivityState.SLEEPING.value == "sleeping"


# ── Palette tests ────────────────────────────────────────────────────


class TestPalette:
    def test_all_16_colors_defined(self):
        assert len(PALETTE) == 16
        assert len(PALETTE_ORDER) == 16

    def test_palette_order_matches_keys(self):
        for name in PALETTE_ORDER:
            assert name in PALETTE

    def test_all_colors_are_valid_rgb(self):
        for name, (r, g, b) in PALETTE.items():
            assert 0 <= r <= 255, f"{name} red out of range: {r}"
            assert 0 <= g <= 255, f"{name} green out of range: {g}"
            assert 0 <= b <= 255, f"{name} blue out of range: {b}"

    def test_hex_to_rgb_valid(self):
        assert hex_to_rgb("#FF0000") == (255, 0, 0)
        assert hex_to_rgb("#00ff00") == (0, 255, 0)
        assert hex_to_rgb("#0000FF") == (0, 0, 255)

    def test_hex_to_rgb_shorthand(self):
        assert hex_to_rgb("#F00") == (255, 0, 0)
        assert hex_to_rgb("#0F0") == (0, 255, 0)

    def test_hex_to_rgb_invalid(self):
        assert hex_to_rgb("not-a-color") is None
        assert hex_to_rgb("#GG0000") is None
        assert hex_to_rgb("#12") is None

    def test_rgb_to_hex(self):
        assert rgb_to_hex(255, 0, 0) == "#FF0000"
        assert rgb_to_hex(0, 255, 0) == "#00FF00"

    def test_resolve_named_color(self):
        assert resolve_color("crimson") == (220, 20, 60)
        assert resolve_color("CRIMSON") == (220, 20, 60)

    def test_resolve_hex_color(self):
        assert resolve_color("#4A90D9") == (74, 144, 217)

    def test_resolve_invalid(self):
        assert resolve_color("nonexistent") is None

    def test_ansi_fg(self):
        result = ansi_fg(255, 0, 0)
        assert result == "\033[38;2;255;0;0m"

    def test_ansi_reset(self):
        assert ansi_reset() == "\033[0m"

    def test_adjust_brightness_lighter(self):
        r, g, b = adjust_brightness((100, 100, 100), 1.3)
        assert r == 130
        assert g == 130
        assert b == 130

    def test_adjust_brightness_darker(self):
        r, g, b = adjust_brightness((100, 100, 100), 0.7)
        assert r == 70
        assert g == 70
        assert b == 70

    def test_adjust_brightness_clamps(self):
        r, g, b = adjust_brightness((200, 200, 200), 2.0)
        assert r == 255
        assert g == 255
        assert b == 255

        r, g, b = adjust_brightness((50, 50, 50), 0.0)
        assert r == 0
        assert g == 0
        assert b == 0

    def test_compute_highlight(self):
        h = compute_highlight((100, 100, 100))
        assert h == (130, 130, 130)

    def test_compute_shadow(self):
        s = compute_shadow((100, 100, 100))
        assert s == (70, 70, 70)

    def test_build_color_map(self):
        cmap = build_color_map("#FF0000", "#00FF00", "#0000FF")
        assert cmap["B"] == (255, 0, 0)
        assert cmap["E"] == (0, 255, 0)
        assert cmap["A"] == (0, 0, 255)
        assert "#" in cmap
        assert "H" in cmap
        assert "S" in cmap

    def test_build_color_map_defaults(self):
        cmap = build_color_map("invalid", "invalid", "invalid")
        # Should fall back to defaults
        assert cmap["B"] == (74, 144, 217)


# ── Species tests ────────────────────────────────────────────────────


class TestSpecies:
    def test_all_species_have_info(self):
        for key in SPECIES_ORDER:
            assert key in SPECIES_INFO

    def test_species_enum_matches(self):
        for sp in Species:
            assert sp.value in SPECIES_INFO

    def test_species_info_fields(self):
        for key, info in SPECIES_INFO.items():
            assert info.key == key
            assert len(info.label) > 0
            assert len(info.description) > 0
            assert len(info.baby_preview) > 0

    def test_evolution_flavor_all_species(self):
        for key in SPECIES_ORDER:
            assert key in EVOLUTION_FLAVOR
            assert "juvenile" in EVOLUTION_FLAVOR[key]
            assert "adult" in EVOLUTION_FLAVOR[key]


# ── Sprite tests ─────────────────────────────────────────────────────


class TestSprites:
    def test_all_species_have_sprites(self):
        for key in SPECIES_ORDER:
            assert key in SPRITES

    def test_all_stages_present(self):
        for species, stages in SPRITES.items():
            for stage in ["baby", "juvenile", "adult"]:
                assert stage in stages, f"{species} missing {stage}"

    def test_all_activities_present(self):
        for species, stages in SPRITES.items():
            for stage_name, activities in stages.items():
                for activity in ["idle", "thinking", "working", "sleeping"]:
                    assert activity in activities, f"{species}/{stage_name} missing {activity}"

    def test_frames_are_nonempty(self):
        for species, stages in SPRITES.items():
            for stage_name, activities in stages.items():
                for activity_name, frames in activities.items():
                    assert len(frames) >= 1, f"{species}/{stage_name}/{activity_name} has no frames"
                    for i, frame in enumerate(frames):
                        assert len(frame) >= 1, (
                            f"{species}/{stage_name}/{activity_name}[{i}] is empty"
                        )

    def test_valid_role_codes(self):
        valid_codes = set(".#BEAHS")
        for species, stages in SPRITES.items():
            for stage_name, activities in stages.items():
                for activity_name, frames in activities.items():
                    for fi, frame in enumerate(frames):
                        for ri, row in enumerate(frame):
                            for ci, ch in enumerate(row):
                                assert ch in valid_codes, (
                                    f"Invalid code '{ch}' in "
                                    f"{species}/{stage_name}/{activity_name}"
                                    f"[{fi}] row {ri} col {ci}"
                                )

    def test_egg_frames(self):
        assert len(EGG_FRAMES) >= 1
        for frame in EGG_FRAMES:
            assert len(frame) >= 1

    def test_get_sprite_frames_valid(self):
        frames = get_sprite_frames("fox", "baby", "idle")
        assert len(frames) >= 1

    def test_get_sprite_frames_fallback(self):
        # Unknown species falls back to fox
        frames = get_sprite_frames("unicorn", "baby", "idle")
        assert frames == SPRITES["fox"]["baby"]["idle"]

        # Unknown activity falls back to idle
        frames = get_sprite_frames("fox", "baby", "dancing")
        assert frames == SPRITES["fox"]["baby"]["idle"]


# ── Renderer tests ───────────────────────────────────────────────────


class TestRenderer:
    def test_render_simple_frame(self):
        frame = [".B.", "BAB", ".B."]
        colors = {
            "B": (255, 0, 0),
            "A": (0, 255, 0),
            "#": (30, 30, 30),
            "H": (255, 100, 100),
            "S": (180, 0, 0),
            "E": (255, 215, 0),
        }
        rendered = render_frame(frame, colors)
        assert len(rendered) == 3
        # First row: space, red block, space
        assert rendered[0].startswith(" ")
        assert "\033[38;2;255;0;0m" in rendered[0]

    def test_render_transparent_dots(self):
        frame = ["..."]
        colors = {"B": (0, 0, 0)}
        rendered = render_frame(frame, colors)
        # Should be 3 spaces (no ANSI codes for dots)
        assert rendered[0] == "   "

    def test_render_plain(self):
        frame = [".B.", "BAB", ".B."]
        rendered = render_frame_plain(frame)
        assert len(rendered) == 3
        assert rendered[0][0] == " "
        assert rendered[0][1] == "\u2588"
        assert rendered[0][2] == " "

    def test_render_outline(self):
        frame = ["#"]
        colors = {"B": (0, 0, 0)}
        rendered = render_frame(frame, colors)
        # '#' should render in dark gray (30, 30, 30)
        assert "\033[38;2;30;30;30m" in rendered[0]


# ── Evolution mapping tests ──────────────────────────────────────────


class TestEvolution:
    def test_stage_mapping(self):
        from familiar.core.constitution import get_relationship_stage

        stage_map = {
            "stage_1": "baby",
            "stage_2": "juvenile",
            "stage_3": "adult",
        }

        # 0 interactions = STAGE_1 = baby
        stage = get_relationship_stage(0)
        assert stage_map[stage.value] == "baby"

        # 10 interactions = STAGE_2 = juvenile
        stage = get_relationship_stage(10)
        assert stage_map[stage.value] == "juvenile"

        # 50 interactions = STAGE_3 = adult
        stage = get_relationship_stage(50)
        assert stage_map[stage.value] == "adult"

        # 100 interactions = still STAGE_3 = adult
        stage = get_relationship_stage(100)
        assert stage_map[stage.value] == "adult"

    def test_evolution_thresholds(self):
        from familiar.core.constitution import get_relationship_stage

        # Just below threshold
        assert get_relationship_stage(9).value == "stage_1"
        # At threshold
        assert get_relationship_stage(10).value == "stage_2"
        # Just below next threshold
        assert get_relationship_stage(49).value == "stage_2"
        # At threshold
        assert get_relationship_stage(50).value == "stage_3"


# ── Display tests ────────────────────────────────────────────────────


class TestDisplay:
    def test_display_creates(self):
        from familiar.creature.display import CreatureDisplay

        state = CreatureState(
            name="TestFox",
            species="fox",
            color_body="#FF0000",
            color_eyes="#00FF00",
            color_accent="#0000FF",
        )
        display = CreatureDisplay(state)
        assert display.state.name == "TestFox"

    def test_display_show(self, capsys):
        from familiar.creature.display import CreatureDisplay

        state = CreatureState(
            name="TestFox",
            species="fox",
            color_body="#FF0000",
            color_eyes="#00FF00",
            color_accent="#0000FF",
        )
        display = CreatureDisplay(state)
        display.show(ActivityState.IDLE)
        captured = capsys.readouterr()
        assert "TestFox" in captured.out
        assert "idle" in captured.out
        # Should contain box drawing characters
        assert "\u250c" in captured.out  # top-left corner
        assert "\u2514" in captured.out  # bottom-left corner

    def test_display_with_status_text(self, capsys):
        from familiar.creature.display import CreatureDisplay

        state = CreatureState(name="Owl", species="owl")
        display = CreatureDisplay(state)
        display.show(ActivityState.WORKING, "Checking email...")
        captured = capsys.readouterr()
        assert "Checking email..." in captured.out


# ── Onboarding fields tests ─────────────────────────────────────


class TestOnboardingFields:
    def test_new_fields_default(self):
        state = CreatureState()
        assert state.owner_name == ""
        assert state.constitution_accepted is False
        assert state.constitution_accepted_at == ""
        assert state.preferred_channel == ""

    def test_save_load_with_new_fields(self, tmp_path):
        creature = create_creature(
            name="BondFox",
            species="fox",
            owner_name="George",
            constitution_accepted=True,
            preferred_channel="telegram",
            data_dir=tmp_path,
        )
        assert creature.owner_name == "George"
        assert creature.constitution_accepted is True
        assert creature.constitution_accepted_at != ""
        assert creature.preferred_channel == "telegram"

        loaded = load_creature(data_dir=tmp_path)
        assert loaded is not None
        assert loaded.owner_name == "George"
        assert loaded.constitution_accepted is True
        assert loaded.constitution_accepted_at != ""
        assert loaded.preferred_channel == "telegram"

    def test_backward_compat_load(self, tmp_path):
        """Old creature.json files (without new fields) load with defaults."""
        path = tmp_path / "creature.json"
        old_data = {
            "name": "OldFox",
            "species": "fox",
            "color_body": "#4A90D9",
            "color_eyes": "#FFD700",
            "color_accent": "#FF6B6B",
            "evolution_stage": "baby",
            "total_interactions": 5,
            "born_at": "2026-01-01T00:00:00",
            "last_seen": "2026-01-01T00:00:00",
            "last_evolution_at": "",
        }
        path.write_text(json.dumps(old_data))

        loaded = load_creature(data_dir=tmp_path)
        assert loaded is not None
        assert loaded.name == "OldFox"
        assert loaded.owner_name == ""
        assert loaded.constitution_accepted is False
        assert loaded.preferred_channel == ""

    def test_constitution_articles_human(self):
        from familiar.core.constitution import CONSTITUTION_ARTICLES_HUMAN

        assert len(CONSTITUTION_ARTICLES_HUMAN) == 8
        for article in CONSTITUTION_ARTICLES_HUMAN:
            assert "title" in article
            assert "summary" in article
            assert len(article["title"]) > 0
            assert len(article["summary"]) > 0


# ── Species voice tests ────────────────────────────────────────────


class TestSpeciesVoice:
    def test_all_species_have_voice_directives(self):
        for key in SPECIES_ORDER:
            assert key in SPECIES_VOICE_DIRECTIVES, f"Missing voice directive for {key}"

    def test_get_species_voice_valid(self):
        for key in SPECIES_ORDER:
            voice = get_species_voice(key)
            assert voice != "", f"Empty voice for {key}"
            assert voice == SPECIES_VOICE_DIRECTIVES[key]

    def test_get_species_voice_unknown(self):
        assert get_species_voice("unicorn") == ""
        assert get_species_voice("") == ""

    def test_get_species_voice_case_insensitive(self):
        assert get_species_voice("FOX") == SPECIES_VOICE_DIRECTIVES["fox"]
        assert get_species_voice("Owl") == SPECIES_VOICE_DIRECTIVES["owl"]

    def test_constitution_build_includes_species_voice(self):
        from familiar.core.constitution import ConstitutionalIdentity

        identity = ConstitutionalIdentity(species_voice="Test voice directive.")
        prompt = identity.build()
        assert "<species_voice>" in prompt
        assert "Test voice directive." in prompt
        assert "</species_voice>" in prompt

    def test_constitution_build_omits_empty_species_voice(self):
        from familiar.core.constitution import ConstitutionalIdentity

        identity = ConstitutionalIdentity(species_voice="")
        prompt = identity.build()
        assert "<species_voice>" not in prompt

    def test_to_dict_includes_has_species_voice(self):
        from familiar.core.constitution import ConstitutionalIdentity

        identity_with = ConstitutionalIdentity(species_voice="some voice")
        assert identity_with.to_dict()["has_species_voice"] is True

        identity_without = ConstitutionalIdentity(species_voice="")
        assert identity_without.to_dict()["has_species_voice"] is False
