"""Unit tests for the IO-free utils module (#82 Phase 1).

These tests depend only on `:utils` so they re-run only when
utils.py itself changes — handler / parser / github edits do not
invalidate them.
"""
from __future__ import annotations

import pytest

from active_claude_github_mcp.utils import (
    SessionActivity,
    check_overwrite_size,
    compose_session_summary,
    meets_confidence_threshold,
    resolve_new_body,
)


class TestMeetsConfidenceThreshold:
    def test_high_meets_high(self):
        assert meets_confidence_threshold("high", "high")

    def test_medium_does_not_meet_high(self):
        assert not meets_confidence_threshold("medium", "high")

    def test_medium_meets_medium(self):
        assert meets_confidence_threshold("medium", "medium")

    def test_low_does_not_meet_medium(self):
        assert not meets_confidence_threshold("low", "medium")

    def test_low_meets_low(self):
        assert meets_confidence_threshold("low", "low")

    def test_high_meets_low(self):
        assert meets_confidence_threshold("high", "low")

    def test_invalid_observed_raises(self):
        with pytest.raises(ValueError, match="observed"):
            meets_confidence_threshold("certain", "high")

    def test_invalid_threshold_raises(self):
        with pytest.raises(ValueError, match="threshold"):
            meets_confidence_threshold("high", "bogus")


class TestComposeSessionSummary:
    def test_empty_activity_returns_explanatory_line(self):
        activity = SessionActivity()
        out = compose_session_summary(
            activity, skill_update_candidates=0, has_pending_buffer=False
        )
        assert "No tracked activity" in out

    def test_full_activity(self):
        activity = SessionActivity(
            turns_posted=12,
            turns_by_thread={"D_1": 5, "I_2": 7},
            discussions_created=1,
            issues_created=2,
            forks_executed=1,
            discussions_decided=1,
            tool_errors=0,
        )
        out = compose_session_summary(
            activity, skill_update_candidates=2, has_pending_buffer=False
        )
        assert "12 turn(s)" in out
        assert "2 thread(s)" in out
        assert "1 Discussion(s)" in out
        assert "2 Issue(s)" in out
        assert "1 fork(s)" in out
        assert "1 Discussion(s) decided" in out
        assert "2 skill update candidate(s)" in out
        assert "caveats" not in out.lower()

    def test_caveats_included(self):
        activity = SessionActivity(
            turns_posted=3,
            turns_by_thread={"D_1": 3},
            tool_errors=2,
        )
        out = compose_session_summary(activity, 0, has_pending_buffer=True)
        assert "2 tool error(s)" in out
        assert "pending buffer non-empty at close" in out

    def test_only_candidates(self):
        activity = SessionActivity()
        out = compose_session_summary(
            activity, skill_update_candidates=3, has_pending_buffer=False
        )
        assert "3 skill update candidate(s)" in out


class TestCheckOverwriteSize:
    def test_force_skips_check(self):
        check_overwrite_size("a" * 10000, "", force=True)

    def test_small_current_is_unconstrained(self):
        check_overwrite_size("tiny", "x", force=False)

    def test_hard_cliff_fires(self):
        # Regression for #48: 10K current overwritten with a 38-char
        # placeholder. Must refuse.
        with pytest.raises(ValueError, match="Refusing overwrite"):
            check_overwrite_size("a" * 10000, "x" * 38, force=False)

    def test_ratio_cliff_fires(self):
        # current > 1000 AND new < 50% of current → refuse
        with pytest.raises(ValueError, match="Refusing overwrite"):
            check_overwrite_size("a" * 2000, "x" * 999, force=False)

    def test_ratio_cliff_allows_at_50_percent(self):
        # new exactly 50% of current → allowed
        check_overwrite_size("a" * 2000, "x" * 1000, force=False)

    def test_modest_shrink_allowed(self):
        # 25% shrink — above the 50% floor
        check_overwrite_size("a" * 2000, "x" * 1500, force=False)

    def test_growth_always_allowed(self):
        check_overwrite_size("a" * 10000, "x" * 15000, force=False)

    def test_force_bypasses_ratio_cliff(self):
        check_overwrite_size("a" * 2000, "x" * 100, force=True)


class TestResolveNewBody:
    """#193 Phase A — `resolve_new_body` is the shared dispatcher
    helper that backs `update_project_toc` and `update_issue_toc`.
    These tests cover the helper in isolation; the handler tests
    cover the end-to-end flow."""

    def test_inline_returned_verbatim(self):
        assert resolve_new_body("inline body", None) == "inline body"

    def test_empty_string_inline_is_valid(self):
        # An empty string is intentional content; only None is "unset."
        assert resolve_new_body("", None) == ""

    def test_file_path_returned_verbatim(self, tmp_path):
        body = "# header\nbody text\n"
        p = tmp_path / "x.md"
        p.write_text(body, encoding="utf-8")

        assert resolve_new_body(None, str(p)) == body

    def test_both_args_rejected(self, tmp_path):
        p = tmp_path / "x.md"
        p.write_text("body", encoding="utf-8")

        with pytest.raises(ValueError, match="exactly one of"):
            resolve_new_body("inline", str(p))

    def test_neither_arg_rejected(self):
        with pytest.raises(ValueError, match="Must pass either"):
            resolve_new_body(None, None)

    def test_relative_path_rejected(self):
        with pytest.raises(ValueError, match="must be absolute"):
            resolve_new_body(None, "relative/x.md")

    def test_missing_file_rejected(self, tmp_path):
        missing = tmp_path / "absent.md"

        with pytest.raises(ValueError, match="not found"):
            resolve_new_body(None, str(missing))


if __name__ == "__main__":
    import sys
    sys.exit(pytest.main([__file__, "-v"]))
