"""Tests for JSON parse error paths in CLI wrapper services.

Task 6.1: Verifies that malformed JSON from bd and openspec CLIs
produces descriptive RuntimeError messages instead of raw JSONDecodeError.
"""

from pathlib import Path
from unittest.mock import AsyncMock, patch

import pytest

from hangar.services.beads import BeadsService
from hangar.services.openspec import OpenSpecService


# ── Helpers ──────────────────────────────────────────────────────


def _mock_process(stdout: str, returncode: int = 0) -> AsyncMock:
    """Create a mock subprocess process."""
    proc = AsyncMock()
    proc.communicate.return_value = (stdout.encode(), b"")
    proc.returncode = returncode
    return proc


# ══════════════════════════════════════════════════════════════════
#  BeadsService JSON error paths
# ══════════════════════════════════════════════════════════════════


class TestBeadsServiceJsonErrors:
    """Test _parse_json error handling in BeadsService."""

    @pytest.fixture
    def service(self, tmp_path: Path) -> BeadsService:
        return BeadsService(project_path=tmp_path)

    @pytest.mark.asyncio
    async def test_malformed_json_in_list_tasks(self, service: BeadsService) -> None:
        """list_tasks with truncated JSON should raise descriptive RuntimeError."""
        malformed = '{"issues": [{"id": "hangar-1", "title": "test"'  # truncated

        with patch("asyncio.create_subprocess_exec", return_value=_mock_process(malformed)):
            with pytest.raises(RuntimeError, match="returned invalid JSON"):
                await service.list_tasks()

    @pytest.mark.asyncio
    async def test_html_instead_of_json(self, service: BeadsService) -> None:
        """list_tasks with HTML output should raise descriptive RuntimeError."""
        html = "<html><body>Error 502</body></html>"

        with patch("asyncio.create_subprocess_exec", return_value=_mock_process(html)):
            with pytest.raises(RuntimeError, match="returned invalid JSON"):
                await service.list_tasks()

    @pytest.mark.asyncio
    async def test_empty_output_in_ready_tasks(self, service: BeadsService) -> None:
        """ready_tasks with empty output should raise descriptive RuntimeError."""
        with patch("asyncio.create_subprocess_exec", return_value=_mock_process("")):
            with pytest.raises(RuntimeError, match="returned invalid JSON"):
                await service.ready_tasks()

    @pytest.mark.asyncio
    async def test_plain_text_in_show_task(self, service: BeadsService) -> None:
        """show_task with plain text should raise descriptive RuntimeError."""
        with patch("asyncio.create_subprocess_exec", return_value=_mock_process("Task not found")):
            with pytest.raises(RuntimeError, match="returned invalid JSON"):
                await service.show_task("hangar-xyz")

    @pytest.mark.asyncio
    async def test_output_preview_in_error_message(self, service: BeadsService) -> None:
        """Error message should include a preview of the failing output."""
        garbage = "GARBAGE_OUTPUT_12345"

        with patch("asyncio.create_subprocess_exec", return_value=_mock_process(garbage)):
            with pytest.raises(RuntimeError, match="GARBAGE_OUTPUT_12345"):
                await service.list_tasks()

    @pytest.mark.asyncio
    async def test_long_output_truncated_in_error(self, service: BeadsService) -> None:
        """Error message should truncate very long output to 200 chars."""
        long_garbage = "x" * 500

        with patch("asyncio.create_subprocess_exec", return_value=_mock_process(long_garbage)):
            try:
                await service.list_tasks()
                assert False, "Should have raised"
            except RuntimeError as e:
                # The output preview should be truncated
                assert len(str(e)) < 500


# ══════════════════════════════════════════════════════════════════
#  OpenSpecService JSON error paths
# ══════════════════════════════════════════════════════════════════


class TestOpenSpecServiceJsonErrors:
    """Test _extract_json error handling in OpenSpecService."""

    def test_truncated_json_after_prefix(self) -> None:
        """Truncated JSON after valid prefix should raise RuntimeError."""
        raw = "Loading...\n{\"changes\": [{\"name\":"  # truncated

        with pytest.raises(RuntimeError, match="Invalid JSON in openspec output"):
            OpenSpecService._extract_json(raw)

    def test_invalid_json_structure(self) -> None:
        """JSON with syntax errors should raise RuntimeError."""
        raw = '{key: "missing quotes on key"}'

        with pytest.raises(RuntimeError, match="Invalid JSON in openspec output"):
            OpenSpecService._extract_json(raw)

    def test_no_json_at_all(self) -> None:
        """Output with no JSON should raise ValueError."""
        raw = "This is just text with no JSON"

        with pytest.raises(ValueError, match="No JSON found"):
            OpenSpecService._extract_json(raw)

    def test_empty_output(self) -> None:
        """Empty output should raise ValueError."""
        with pytest.raises(ValueError, match="No JSON found"):
            OpenSpecService._extract_json("")

    def test_output_preview_in_error(self) -> None:
        """Error message should include preview of the failing output."""
        raw = "PREFIX_LINE\n{broken json here 12345"

        with pytest.raises(RuntimeError, match="broken json here 12345"):
            OpenSpecService._extract_json(raw)

    def test_valid_json_with_prefix_works(self) -> None:
        """Valid JSON after non-JSON prefix should parse correctly."""
        raw = "- Loading...\n- Checking...\n{\"changes\": []}"

        result = OpenSpecService._extract_json(raw)
        assert result == {"changes": []}

    def test_valid_json_array(self) -> None:
        """Valid JSON array should parse correctly."""
        raw = '[{"id": 1}]'

        result = OpenSpecService._extract_json(raw)
        assert result == [{"id": 1}]


# ══════════════════════════════════════════════════════════════════
#  GitDiffService error paths
# ══════════════════════════════════════════════════════════════════


class TestGitDiffServiceErrors:
    """Test GitDiffService with invalid task IDs and git failures."""

    @pytest.fixture
    def service(self, tmp_path: Path):
        from hangar.services.gitdiff import GitDiffService
        return GitDiffService(project_path=tmp_path)

    @pytest.mark.asyncio
    async def test_invalid_task_id_rejected(self, service) -> None:
        """get_task_diff should reject task IDs with special characters."""
        with pytest.raises(ValueError, match="Invalid task ID"):
            await service.get_task_diff("../../etc/passwd")

    @pytest.mark.asyncio
    async def test_semicolon_in_task_id_rejected(self, service) -> None:
        """get_task_diff should reject task IDs with shell injection attempts."""
        with pytest.raises(ValueError, match="Invalid task ID"):
            await service.get_task_diff("hangar-abc; rm -rf /")

    @pytest.mark.asyncio
    async def test_valid_task_id_passes_validation(self, service) -> None:
        """get_task_diff should accept valid task IDs (then fail on git, not validation)."""
        with patch("asyncio.create_subprocess_exec", return_value=_mock_process("")):
            result = await service.get_task_diff("hangar-abc")
            assert result.task_id == "hangar-abc"
            assert result.commits == []
