"""
Unit tests for AI commit message generation.

Tests use mocked Claude SDK responses to avoid actual API calls.
"""

import pytest
from unittest.mock import Mock, MagicMock, patch, AsyncMock
from pathlib import Path
import sys
import asyncio

# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))

from orchestrator_auto.commit_ai import (
    generate_smart_commit_message,
    generate_smart_commit_message_async,
    DEFAULT_MODEL,
    DEFAULT_TIMEOUT,
    MAX_MESSAGE_LENGTH,
    MAX_SUBJECT_LENGTH,
    VALID_TYPES,
    _build_prompt,
    _strip_code_fences,
    _contains_ai_mentions,
    _validate_format,
    _clean_and_validate,
)


class TestBuildPrompt:
    """Test prompt building function."""

    def test_build_prompt_basic(self):
        """Test basic prompt building."""
        diff = "+def hello(): pass"
        stats = {"files_changed": 1, "insertions": 1, "deletions": 0}
        feature_hint = "Add greeting function"

        prompt = _build_prompt(diff, stats, feature_hint)

        assert "Add greeting function" in prompt
        assert "1 files changed" in prompt
        assert "1 insertions" in prompt
        assert "0 deletions" in prompt
        assert "+def hello(): pass" in prompt

    def test_build_prompt_truncates_large_diff(self):
        """Test that large diffs are truncated in prompt."""
        large_diff = "x" * 10000
        stats = {"files_changed": 1, "insertions": 100, "deletions": 0}

        prompt = _build_prompt(large_diff, stats, "")

        # Should be truncated to 6000 chars for diff portion
        assert len(prompt) < 10000

    def test_build_prompt_empty_feature_hint(self):
        """Test prompt with empty feature hint."""
        diff = "+code"
        stats = {"files_changed": 1, "insertions": 1, "deletions": 0}

        prompt = _build_prompt(diff, stats, "")

        assert "Code changes" in prompt  # Default hint

    def test_build_prompt_missing_stats(self):
        """Test prompt with missing stats keys."""
        diff = "+code"
        stats = {}  # Empty stats

        prompt = _build_prompt(diff, stats, "Test")

        assert "0 files changed" in prompt
        assert "0 insertions" in prompt
        assert "0 deletions" in prompt


class TestStripCodeFences:
    """Test code fence stripping."""

    def test_strip_basic_code_fence(self):
        """Test stripping basic code fences."""
        text = "```\nfeat: add feature\n```"
        result = _strip_code_fences(text)
        assert result == "feat: add feature"

    def test_strip_language_code_fence(self):
        """Test stripping code fences with language."""
        text = "```commit\nfeat: add feature\n```"
        result = _strip_code_fences(text)
        assert result == "feat: add feature"

    def test_strip_text_code_fence(self):
        """Test stripping text code fences."""
        text = "```text\nfeat: add feature\n```"
        result = _strip_code_fences(text)
        assert result == "feat: add feature"

    def test_no_code_fence(self):
        """Test text without code fences."""
        text = "feat: add feature"
        result = _strip_code_fences(text)
        assert result == "feat: add feature"

    def test_preserves_internal_content(self):
        """Test that internal content is preserved."""
        text = "```\nfeat: add feature\n\n- bullet 1\n- bullet 2\n```"
        result = _strip_code_fences(text)
        assert "feat: add feature" in result
        assert "- bullet 1" in result
        assert "- bullet 2" in result


class TestContainsAiMentions:
    """Test AI mention detection."""

    def test_detects_claude_mention(self):
        """Test detection of Claude mention."""
        assert _contains_ai_mentions("Generated by Claude") is True

    def test_detects_ai_mention(self):
        """Test detection of AI mention."""
        assert _contains_ai_mentions("This was created by AI") is True

    def test_detects_anthropic_mention(self):
        """Test detection of Anthropic mention."""
        assert _contains_ai_mentions("Anthropic generated this") is True

    def test_detects_generated_with(self):
        """Test detection of 'generated with' pattern."""
        assert _contains_ai_mentions("Generated with LLM") is True

    def test_detects_language_model(self):
        """Test detection of 'language model' mention."""
        assert _contains_ai_mentions("Created by language model") is True

    def test_clean_message_passes(self):
        """Test that clean messages pass."""
        assert _contains_ai_mentions("feat: add user authentication") is False
        assert _contains_ai_mentions("fix: resolve login bug") is False

    def test_case_insensitive(self):
        """Test case insensitive detection."""
        assert _contains_ai_mentions("CLAUDE") is True
        assert _contains_ai_mentions("claude") is True
        assert _contains_ai_mentions("Claude") is True


class TestValidateFormat:
    """Test Conventional Commits format validation."""

    def test_valid_feat(self):
        """Test valid feat commit."""
        assert _validate_format("feat: add new feature") is True

    def test_valid_fix(self):
        """Test valid fix commit."""
        assert _validate_format("fix: resolve bug") is True

    def test_valid_refactor(self):
        """Test valid refactor commit."""
        assert _validate_format("refactor: restructure code") is True

    def test_valid_docs(self):
        """Test valid docs commit."""
        assert _validate_format("docs: update readme") is True

    def test_valid_test(self):
        """Test valid test commit."""
        assert _validate_format("test: add unit tests") is True

    def test_valid_chore(self):
        """Test valid chore commit."""
        assert _validate_format("chore: update dependencies") is True

    def test_valid_style(self):
        """Test valid style commit."""
        assert _validate_format("style: format code") is True

    def test_valid_perf(self):
        """Test valid perf commit."""
        assert _validate_format("perf: optimize query") is True

    def test_valid_with_body(self):
        """Test valid commit with body."""
        message = """feat: add user authentication

- Add login endpoint
- Add logout endpoint
- Add session management"""
        assert _validate_format(message) is True

    def test_invalid_no_colon(self):
        """Test invalid commit without colon."""
        assert _validate_format("feat add new feature") is False

    def test_invalid_unknown_type(self):
        """Test invalid commit with unknown type."""
        assert _validate_format("feature: add new thing") is False

    def test_invalid_empty(self):
        """Test invalid empty message."""
        assert _validate_format("") is False
        assert _validate_format(None) is False

    def test_invalid_no_description(self):
        """Test invalid commit without description."""
        assert _validate_format("feat:") is False

    def test_case_insensitive_type(self):
        """Test that type is case insensitive."""
        assert _validate_format("FEAT: add feature") is True
        assert _validate_format("Feat: add feature") is True

    def test_valid_with_scope(self):
        """Test valid commit with scope."""
        assert _validate_format("feat(auth): add login endpoint") is True
        assert _validate_format("fix(api): resolve timeout issue") is True
        assert _validate_format("refactor(core): restructure module") is True

    def test_valid_with_breaking_change(self):
        """Test valid commit with breaking change marker."""
        assert _validate_format("feat!: breaking change in API") is True
        assert _validate_format("fix!: change return type") is True

    def test_valid_with_scope_and_breaking_change(self):
        """Test valid commit with both scope and breaking change."""
        assert _validate_format("feat(api)!: breaking change to endpoints") is True
        assert _validate_format("refactor(core)!: major restructure") is True


class TestCleanAndValidate:
    """Test full clean and validate pipeline."""

    def test_valid_message_passes(self):
        """Test that valid messages pass through."""
        message = "feat: add new feature"
        result = _clean_and_validate(message)
        assert result == "feat: add new feature"

    def test_strips_code_fences(self):
        """Test that code fences are stripped."""
        message = "```\nfeat: add new feature\n```"
        result = _clean_and_validate(message)
        assert result == "feat: add new feature"

    def test_rejects_ai_mentions(self):
        """Test that AI mentions are rejected."""
        message = "feat: add new feature (generated by Claude)"
        result = _clean_and_validate(message)
        assert result is None

    def test_rejects_invalid_format(self):
        """Test that invalid format is rejected."""
        message = "This is not a valid commit message"
        result = _clean_and_validate(message)
        assert result is None

    def test_rejects_empty(self):
        """Test that empty messages are rejected."""
        assert _clean_and_validate("") is None
        assert _clean_and_validate(None) is None

    def test_truncates_long_message(self):
        """Test that long messages are truncated."""
        body_lines = "\n- " + "x" * 100
        message = "feat: add feature" + body_lines * 10
        result = _clean_and_validate(message)

        assert result is not None
        assert len(result) <= MAX_MESSAGE_LENGTH
        assert result.endswith("...")

    def test_preserves_body(self):
        """Test that message body is preserved."""
        message = """feat: add user authentication

- Add login endpoint
- Add logout endpoint"""
        result = _clean_and_validate(message)

        assert "feat: add user authentication" in result
        assert "Add login endpoint" in result
        assert "Add logout endpoint" in result


class TestValidTypes:
    """Test that VALID_TYPES constant is correct."""

    def test_valid_types_complete(self):
        """Test that all expected types are present."""
        expected = {"feat", "fix", "refactor", "docs", "test", "chore", "style", "perf"}
        assert VALID_TYPES == expected

    def test_valid_types_count(self):
        """Test that we have exactly 8 types."""
        assert len(VALID_TYPES) == 8


class TestDefaults:
    """Test default constants."""

    def test_default_model(self):
        """Test default model is Sonnet."""
        assert DEFAULT_MODEL == "claude-sonnet-4-5-20250929"

    def test_default_timeout(self):
        """Test default timeout is 30 seconds."""
        assert DEFAULT_TIMEOUT == 30

    def test_max_message_length(self):
        """Test max message length is reasonable."""
        assert MAX_MESSAGE_LENGTH == 500

    def test_max_subject_length(self):
        """Test max subject length follows git convention."""
        assert MAX_SUBJECT_LENGTH == 72


class TestSubjectLineTruncation:
    """Test 72-char subject line enforcement."""

    def test_short_subject_unchanged(self):
        """Test that short subjects are not modified."""
        message = "feat: add feature"
        result = _clean_and_validate(message)
        assert result == "feat: add feature"

    def test_exactly_72_chars_unchanged(self):
        """Test that exactly 72-char subjects are not modified."""
        # Create a subject that's exactly 72 chars
        subject = "feat: " + "x" * 66  # 6 + 66 = 72
        assert len(subject) == 72
        result = _clean_and_validate(subject)
        assert result == subject

    def test_long_subject_truncated(self):
        """Test that long subjects are truncated to 72 chars."""
        # Create a subject that's too long
        subject = "feat: " + "x" * 100  # 106 chars total
        result = _clean_and_validate(subject)
        assert result is not None
        assert len(result) <= MAX_SUBJECT_LENGTH

    def test_truncates_at_word_boundary(self):
        """Test that truncation prefers word boundaries."""
        subject = "feat: add a really long feature description that exceeds the maximum allowed length for commit subjects"
        result = _clean_and_validate(subject)
        assert result is not None
        assert len(result) <= MAX_SUBJECT_LENGTH
        # Should end at a word boundary (space before last word in original)
        # The truncated result should be a prefix of the original that ends at a word break
        assert subject.startswith(result)
        # If truncated, the next char in original should be a space (word boundary)
        if len(result) < len(subject):
            assert subject[len(result)] == " ", f"Truncation split mid-word: '{result}'"

    def test_long_subject_with_body_preserved(self):
        """Test that body is preserved when subject is truncated."""
        message = "feat: " + "x" * 100 + "\n\n- bullet point"
        result = _clean_and_validate(message)
        assert result is not None
        lines = result.split("\n")
        assert len(lines[0]) <= MAX_SUBJECT_LENGTH
        assert "bullet point" in result


class TestGenerateSmartCommitMessageAsync:
    """Test async commit message generation with mocked SDK."""

    @pytest.fixture
    def mock_text_block(self):
        """Create a mock TextBlock."""
        block = Mock()
        block.text = "feat: add new feature\n\n- Add feature implementation"
        return block

    @pytest.fixture
    def mock_assistant_message(self, mock_text_block):
        """Create a mock AssistantMessage."""
        message = Mock(spec=["content"])
        message.content = [mock_text_block]
        # Make isinstance check work
        message.__class__ = type("AssistantMessage", (), {})
        return message

    @pytest.fixture
    def mock_result_message(self):
        """Create a mock ResultMessage."""
        message = Mock()
        message.__class__ = type("ResultMessage", (), {})
        return message

    @pytest.mark.asyncio
    async def test_generates_feat_message(self):
        """Test generation of feat commit message."""
        diff = "+def new_feature(): pass"
        stats = {"files_changed": 1, "insertions": 1, "deletions": 0}

        with patch("orchestrator_auto.commit_ai.ClaudeSDKClient") as mock_client_class:
            # Create mock client
            mock_client = AsyncMock()

            # Mock the async context manager
            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)

            # Create mock messages
            mock_text = Mock()
            mock_text.text = "feat: add new feature\n\n- Add feature implementation"

            mock_assistant = Mock()
            mock_assistant.content = [mock_text]

            mock_result = Mock()

            # Setup receive_messages to yield messages
            async def mock_receive():
                # Patch isinstance checks
                with patch("orchestrator_auto.commit_ai.AssistantMessage", type(mock_assistant)):
                    with patch("orchestrator_auto.commit_ai.TextBlock", type(mock_text)):
                        with patch("orchestrator_auto.commit_ai.ResultMessage", type(mock_result)):
                            yield mock_assistant
                            yield mock_result

            mock_client.receive_messages = mock_receive

            result = await generate_smart_commit_message_async(
                diff=diff,
                stats=stats,
                feature_hint="Add feature",
            )

        # Should return valid message or None (depending on mock setup)
        # The important thing is it doesn't crash
        assert result is None or isinstance(result, str)

    @pytest.mark.asyncio
    async def test_returns_none_on_empty_diff(self):
        """Test that empty diff returns None."""
        result = await generate_smart_commit_message_async(
            diff="",
            stats={"files_changed": 0, "insertions": 0, "deletions": 0},
        )
        assert result is None

    @pytest.mark.asyncio
    async def test_returns_none_on_timeout(self):
        """Test that timeout returns None."""
        with patch("orchestrator_auto.commit_ai.ClaudeSDKClient") as mock_client_class:
            # Make client hang
            async def slow_enter():
                await asyncio.sleep(100)
                return Mock()

            mock_client_class.return_value.__aenter__ = slow_enter

            result = await generate_smart_commit_message_async(
                diff="+code",
                stats={"files_changed": 1, "insertions": 1, "deletions": 0},
                timeout=1,  # 1 second timeout
            )

        assert result is None

    @pytest.mark.asyncio
    async def test_returns_none_on_exception(self):
        """Test that exceptions return None."""
        with patch("orchestrator_auto.commit_ai.ClaudeSDKClient") as mock_client_class:
            # Make client raise exception
            mock_client_class.return_value.__aenter__ = AsyncMock(
                side_effect=Exception("API Error")
            )

            result = await generate_smart_commit_message_async(
                diff="+code",
                stats={"files_changed": 1, "insertions": 1, "deletions": 0},
            )

        assert result is None


class TestGenerateSmartCommitMessageSync:
    """Test sync wrapper for commit message generation."""

    def test_sync_wrapper_calls_async(self):
        """Test that sync wrapper calls async function."""
        with patch("orchestrator_auto.commit_ai.asyncio.run") as mock_run:
            mock_run.return_value = "feat: test"

            result = generate_smart_commit_message(
                diff="+code",
                stats={"files_changed": 1, "insertions": 1, "deletions": 0},
            )

        mock_run.assert_called_once()
        assert result == "feat: test"

    def test_sync_wrapper_returns_none_on_error(self):
        """Test that sync wrapper returns None on error."""
        with patch("orchestrator_auto.commit_ai.asyncio.run") as mock_run:
            mock_run.side_effect = Exception("Runtime error")

            result = generate_smart_commit_message(
                diff="+code",
                stats={"files_changed": 1, "insertions": 1, "deletions": 0},
            )

        assert result is None


class TestNoAiMentionInOutput:
    """Critical tests: ensure no AI mentions in output."""

    def test_rejects_claude_in_response(self):
        """Test that Claude mentions are rejected."""
        response = "feat: add feature\n\nGenerated by Claude"
        result = _clean_and_validate(response)
        assert result is None

    def test_rejects_ai_attribution(self):
        """Test that AI attribution is rejected."""
        response = "feat: add feature\n\n🤖 Generated with AI"
        result = _clean_and_validate(response)
        assert result is None

    def test_rejects_anthropic_mention(self):
        """Test that Anthropic mentions are rejected."""
        response = "feat: add feature\n\nCo-authored-by: Anthropic"
        result = _clean_and_validate(response)
        assert result is None

    def test_accepts_clean_response(self):
        """Test that clean responses are accepted."""
        response = "feat: add user authentication\n\n- Add login endpoint\n- Add session management"
        result = _clean_and_validate(response)
        assert result is not None
        assert "Claude" not in result
        assert "AI" not in result
        assert "Anthropic" not in result


class TestCommitMessageTypes:
    """Test different commit message types."""

    @pytest.mark.parametrize("commit_type", ["feat", "fix", "refactor", "docs", "test", "chore", "style", "perf"])
    def test_all_types_valid(self, commit_type):
        """Test that all commit types are valid."""
        message = f"{commit_type}: test description"
        result = _clean_and_validate(message)
        assert result is not None
        assert result.startswith(f"{commit_type}:")
