from __future__ import annotations

import json
from typing import Any

import attrs
import pytest

from blackbox.models import (
    ProjectStats,
    Recommendation,
    SessionAudit,
    SessionDigest,
    SessionEvent,
    WeekStats,
    arrow,
    sparkline,
)
from tests.conftest import make_audit, make_meta

# ---------------------------------------------------------------------------
# sparkline and arrow
# ---------------------------------------------------------------------------


class TestSparkline:
    def test_empty_or_single_returns_empty(self) -> None:
        assert "" == sparkline([])
        assert "" == sparkline([1.0])

    def test_ascending_produces_increasing_chars(self) -> None:
        result = sparkline([0.0, 0.5, 1.0])
        assert len(result) == 3
        assert result[0] <= result[-1]

    def test_descending_produces_decreasing_chars(self) -> None:
        result = sparkline([1.0, 0.5, 0.0])
        assert result[0] >= result[-1]

    def test_constant_values_produce_middle_char(self) -> None:
        result = sparkline([5.0, 5.0, 5.0])
        assert len(result) == 3
        assert len(set(result)) == 1

    def test_two_values_uses_full_range(self) -> None:
        result = sparkline([0.0, 1.0])
        assert len(result) == 2
        assert result[0] != result[-1]


class TestArrow:
    def test_near_zero_delta_returns_equals(self) -> None:
        assert "=" == arrow(0.0)
        assert "=" == arrow(0.04)
        assert "=" == arrow(-0.04)

    def test_positive_delta_returns_up(self) -> None:
        assert "^" == arrow(0.1)

    def test_negative_delta_returns_down(self) -> None:
        assert "v" == arrow(-0.1)

    def test_invert_flips_positive(self) -> None:
        assert "v" == arrow(0.1, invert=True)

    def test_invert_flips_negative(self) -> None:
        assert "^" == arrow(-0.1, invert=True)

    def test_invert_near_zero_still_equals(self) -> None:
        assert "=" == arrow(0.0, invert=True)


# ---------------------------------------------------------------------------
# SessionEvent
# ---------------------------------------------------------------------------


class TestSessionEvent:
    def test_construction(self) -> None:
        e = SessionEvent(
            timestamp="2024-01-01T00:00:00Z",
            speaker="user",
            text="hello",
            tool_name=None,
            file_path=None,
            command=None,
            is_error=False,
            error_category=None,
            attachment_type=None,
        )
        assert e.speaker == "user"
        assert e.text == "hello"
        assert not e.is_error

    def test_frozen(self) -> None:
        e = SessionEvent("ts", "user", "hi", None, None, None, False, None, None)
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            e.speaker = "assistant"  # type: ignore[misc]

    def test_equality(self) -> None:
        e1 = SessionEvent("ts", "user", "hi", None, None, None, False, None, None)
        e2 = SessionEvent("ts", "user", "hi", None, None, None, False, None, None)
        assert e1 == e2

    def test_attrs_asdict(self) -> None:
        e = SessionEvent("ts", "user", "hi", None, None, None, False, None, None)
        d = attrs.asdict(e)
        assert d["speaker"] == "user"
        assert d["text"] == "hi"
        assert json.dumps(d)  # JSON-serializable


# ---------------------------------------------------------------------------
# SessionMeta — properties
# ---------------------------------------------------------------------------


class TestSessionMetaProperties:
    def test_duration_minutes(self) -> None:
        assert make_meta(duration_s=3600.0).duration_minutes == 60.0

    def test_duration_minutes_zero(self) -> None:
        assert make_meta(duration_s=0.0).duration_minutes == 0.0

    def test_total_tokens(self) -> None:
        assert make_meta(input_tokens=1000, output_tokens=500).total_tokens == 1500

    def test_total_tokens_default(self) -> None:
        assert make_meta().total_tokens == 0

    def test_cache_hit_rate(self) -> None:
        meta = make_meta(input_tokens=500, cache_read_tokens=300, cache_creation_tokens=200)
        assert meta.cache_hit_rate == 0.3

    def test_cache_hit_rate_zero_tokens(self) -> None:
        assert make_meta().cache_hit_rate == 0.0

    def test_cache_hit_rate_full(self) -> None:
        meta = make_meta(input_tokens=0, cache_read_tokens=1000, cache_creation_tokens=0)
        assert meta.cache_hit_rate == 1.0


class TestSessionMetaFrozen:
    def test_frozen(self) -> None:
        meta = make_meta()
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            meta.session_id = "new"  # type: ignore[misc]


class TestSessionMetaAsDict:
    def test_returns_dict(self) -> None:
        d = attrs.asdict(make_meta())
        assert isinstance(d, dict)
        assert d["session_id"] == "abcd1234-5678-9012-3456-789012345678"
        assert d["duration_s"] == 3600.0

    def test_includes_optional_fields(self) -> None:
        d = attrs.asdict(make_meta(git_branch="feature", git_commits=3))
        assert d["git_branch"] == "feature"
        assert d["git_commits"] == 3


# ---------------------------------------------------------------------------
# SessionAudit
# ---------------------------------------------------------------------------


class TestSessionAuditDefaults:
    def test_defaults(self) -> None:
        a = SessionAudit(session_id="x")
        assert a.outcome == "unclear"
        assert a.satisfaction == "neutral"
        assert a.session_type == "single_task"
        assert a.goal_categories == {}
        assert a.friction_counts == {}
        assert a.user_instructions == ()
        assert a.summary == ""

    def test_frozen(self) -> None:
        a = SessionAudit(session_id="x")
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            a.outcome = "success"  # type: ignore[misc]


class TestSessionAuditAsDict:
    def test_returns_dict(self) -> None:
        a = make_audit()
        d = attrs.asdict(a)
        assert isinstance(d, dict)
        assert d["outcome"] == "mostly_achieved"

    def test_reflects_values(self) -> None:
        a = make_audit(outcome="success", satisfaction="positive", session_type="multi_task")
        d = attrs.asdict(a)
        assert d["outcome"] == "success"
        assert d["session_type"] == "multi_task"


# ---------------------------------------------------------------------------
# ProjectStats — mutable is_outlier
# ---------------------------------------------------------------------------


class TestProjectStats:
    def make(self, **kw: Any) -> ProjectStats:
        defaults: dict[str, Any] = {
            "project_path": "/proj/myapp",
            "project_name": "myapp",
            "session_count": 10,
            "success_rate": 0.9,
            "avg_tool_errors": 2.5,
            "avg_duration_s": 600.0,
            "top_error_categories": (),
            "top_friction": (),
        }
        defaults.update(kw)
        return ProjectStats(**defaults)

    def test_is_outlier_default_false(self) -> None:
        assert not self.make().is_outlier

    def test_is_outlier_mutable(self) -> None:
        p = self.make()
        p.is_outlier = True
        assert p.is_outlier


# ---------------------------------------------------------------------------
# WeekStats + Recommendation
# ---------------------------------------------------------------------------


class TestWeekStats:
    def test_frozen(self) -> None:
        w = WeekStats(
            week="2026-W17", session_count=3, success_rate=0.7, avg_errors_per_session=1.0, avg_duration_s=600.0
        )
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            w.session_count = 5  # type: ignore[misc]

    def test_default_error_counts(self) -> None:
        w = WeekStats(
            week="2026-W17", session_count=3, success_rate=0.7, avg_errors_per_session=1.0, avg_duration_s=600.0
        )
        assert w.error_category_counts == {}


class TestRecommendation:
    def test_frozen(self) -> None:
        r = Recommendation(suggestion="do X", evidence="50%", frequency=0.5, source_sessions=5)
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            r.suggestion = "do Y"  # type: ignore[misc]


# ---------------------------------------------------------------------------
# SessionDigest
# ---------------------------------------------------------------------------


class TestSessionDigest:
    def make(self, **kw: Any) -> SessionDigest:
        defaults: dict[str, Any] = {"session_count": 10, "date_range": (100.0, 500.0), "success_rate": 0.8}
        defaults.update(kw)
        return SessionDigest(**defaults)

    def test_attrs_asdict(self) -> None:
        d = attrs.asdict(self.make())
        assert d["session_count"] == 10
        assert d["success_rate"] == 0.8

    def test_frozen(self) -> None:
        d = self.make()
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            d.session_count = 99  # type: ignore[misc]

    def test_json_serializable(self) -> None:
        j = json.dumps(attrs.asdict(self.make(session_count=5)), indent=2, default=str)
        parsed = json.loads(j)
        assert parsed["session_count"] == 5

    def test_json_with_nested_weeks(self) -> None:
        w = WeekStats(
            week="2026-W17", session_count=3, success_rate=0.7, avg_errors_per_session=1.0, avg_duration_s=600.0
        )
        j = json.dumps(attrs.asdict(self.make(weeks=(w,))), indent=2, default=str)
        parsed = json.loads(j)
        assert len(parsed["weeks"]) == 1
        assert parsed["weeks"][0]["week"] == "2026-W17"

    def test_json_with_nested_projects(self) -> None:
        p = ProjectStats(
            project_path="/p",
            project_name="p",
            session_count=5,
            success_rate=0.8,
            avg_tool_errors=1.0,
            avg_duration_s=300.0,
            top_error_categories=(),
            top_friction=(),
            is_outlier=False,
        )
        j = json.dumps(attrs.asdict(self.make(projects=(p,))), indent=2, default=str)
        parsed = json.loads(j)
        assert len(parsed["projects"]) == 1
        assert parsed["projects"][0]["project_name"] == "p"
