"""Unit tests for BugService.

Tests verify that:
1. list_bugs returns correctly formatted bug list with pagination (test_ids mode)
2. list_bugs resolves test_ids from product_ids (product_ids mode)
3. list_bugs validates mutual exclusivity of test_ids and product_ids
4. list_bugs applies default statuses in product_ids mode
5. get_bug_summary returns correctly formatted bug summary with metadata
6. get_bug_summary raises BugNotFoundException when bug not found

STORY-084: list_bugs tool implementation
STORY-085: get_bug_summary tool implementation
STORY-XXX: product_ids mode for list_bugs
"""

from datetime import UTC, datetime
from unittest.mock import AsyncMock

import pytest

from testio_mcp.exceptions import BugNotFoundException, ValidationError
from testio_mcp.services.bug_service import BugService

# =============================================================================
# list_bugs - Test Mode (test_ids provided)
# =============================================================================


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_test_mode_returns_bugs() -> None:
    """Verify list_bugs returns bug list with pagination in test_ids mode."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()

    # Mock test_repo.get_tests_cached_or_refresh (staleness check)
    mock_test_repo.get_tests_cached_or_refresh.return_value = (
        {123: {"id": 123, "status": "archived"}},  # tests_dict
        {"cache_hit_rate": 100.0, "api_calls": 0},  # test_stats
    )

    # Mock bug_repo.list_bugs
    mock_bugs = [
        {
            "id": 1,
            "title": "Bug 1",
            "severity": "high",
            "status": "forwarded",
            "test_id": 123,
            "reported_at": "2025-01-01T00:00:00+00:00",
        },
        {
            "id": 2,
            "title": "Bug 2",
            "severity": "low",
            "status": "rejected",
            "test_id": 123,
            "reported_at": "2025-01-02T00:00:00+00:00",
        },
    ]
    mock_bug_repo.list_bugs.return_value = (mock_bugs, 2, [123])

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.list_bugs(test_ids=[123])

    # Assert
    assert len(result["bugs"]) == 2
    assert result["bugs"][0]["id"] == 1
    assert result["bugs"][1]["id"] == 2
    assert result["pagination"]["total_count"] == 2
    assert result["pagination"]["page"] == 1
    assert result["pagination"]["per_page"] == 100
    assert result["filters_applied"]["test_ids"] == [123]
    assert "product_ids" not in result["filters_applied"]


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_test_mode_with_filters() -> None:
    """Verify list_bugs applies bug filters in test_ids mode."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    mock_test_repo.get_tests_cached_or_refresh.return_value = (
        {123: {"id": 123, "status": "archived"}},
        {"cache_hit_rate": 100.0},
    )
    mock_bug_repo.list_bugs.return_value = ([], 0, [])

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.list_bugs(
        test_ids=[123],
        status=["rejected"],
        severity=["critical", "high"],
        rejection_reason=["test_is_invalid"],
    )

    # Assert - filters passed to repository
    mock_bug_repo.list_bugs.assert_called_once()
    call_kwargs = mock_bug_repo.list_bugs.call_args.kwargs
    assert call_kwargs["status"] == ["rejected"]
    assert call_kwargs["severity"] == ["critical", "high"]
    assert call_kwargs["rejection_reason"] == ["test_is_invalid"]

    # Assert - filters_applied in response
    assert result["filters_applied"]["status"] == ["rejected"]
    assert result["filters_applied"]["severity"] == ["critical", "high"]
    assert result["filters_applied"]["rejection_reason"] == ["test_is_invalid"]


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_test_mode_pagination() -> None:
    """Verify list_bugs handles pagination correctly in test_ids mode."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    mock_test_repo.get_tests_cached_or_refresh.return_value = (
        {123: {"id": 123, "status": "archived"}},
        {"cache_hit_rate": 100.0},
    )
    mock_bug_repo.list_bugs.return_value = (
        [
            {
                "id": 6,
                "title": "Bug 6",
                "severity": "low",
                "status": "forwarded",
                "test_id": 123,
                "reported_at": "2025-01-06",
            }
        ],
        10,  # total_count
        [123],
    )

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.list_bugs(test_ids=[123], page=2, per_page=5)

    # Assert
    assert result["pagination"]["page"] == 2
    assert result["pagination"]["per_page"] == 5
    assert result["pagination"]["total_count"] == 10
    assert result["pagination"]["has_more"] is True  # 6-10 still available
    assert result["pagination"]["start_index"] == 5
    assert result["pagination"]["end_index"] == 5


# =============================================================================
# list_bugs - Product Mode (product_ids provided)
# =============================================================================


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_product_mode_resolves_tests() -> None:
    """Verify list_bugs resolves test_ids from product_ids."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()

    # Mock test resolution from products
    mock_test_repo.get_test_ids_for_products.return_value = [123, 456]

    # Mock staleness check
    mock_test_repo.get_tests_cached_or_refresh.return_value = (
        {123: {"id": 123}, 456: {"id": 456}},
        {"cache_hit_rate": 100.0},
    )

    # Mock bug query
    mock_bug_repo.list_bugs.return_value = (
        [
            {
                "id": 1,
                "title": "Bug 1",
                "severity": "high",
                "status": "forwarded",
                "test_id": 123,
                "reported_at": "2025-01-01",
            }
        ],
        1,
        [123],
    )

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.list_bugs(product_ids=[598])

    # Assert - test resolution called with defaults
    mock_test_repo.get_test_ids_for_products.assert_called_once()
    call_kwargs = mock_test_repo.get_test_ids_for_products.call_args.kwargs
    assert call_kwargs["product_ids"] == [598]
    # Default statuses applied
    assert "running" in call_kwargs["statuses"]
    assert "initialized" not in call_kwargs["statuses"]
    assert "cancelled" not in call_kwargs["statuses"]

    # Assert - response shows product_ids mode
    assert result["filters_applied"]["product_ids"] == [598]
    assert "test_ids" not in result["filters_applied"]
    assert result["filters_applied"]["resolved_test_count"] == 2

    # Assert - bugs queried with primary_sort_by_test_end_at=True
    bug_call_kwargs = mock_bug_repo.list_bugs.call_args.kwargs
    assert bug_call_kwargs["primary_sort_by_test_end_at"] is True


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_product_mode_with_date_filters() -> None:
    """Verify list_bugs applies date filters in product_ids mode."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    mock_test_repo.get_test_ids_for_products.return_value = [123]
    mock_test_repo.get_tests_cached_or_refresh.return_value = ({123: {}}, {"cache_hit_rate": 100.0})
    mock_bug_repo.list_bugs.return_value = ([], 0, [])

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.list_bugs(
        product_ids=[598],
        start_date="2025-01-01",
        end_date="2025-01-31",
    )

    # Assert - dates parsed and passed to test resolution
    call_kwargs = mock_test_repo.get_test_ids_for_products.call_args.kwargs
    assert call_kwargs["start_date"] is not None
    assert call_kwargs["end_date"] is not None
    assert call_kwargs["date_field"] == "end_at"

    # Assert - dates in filters_applied
    assert result["filters_applied"]["start_date"] == "2025-01-01"
    assert result["filters_applied"]["end_date"] == "2025-01-31"


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_product_mode_empty_test_resolution() -> None:
    """Verify list_bugs returns empty result when no tests match filters."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    mock_test_repo.get_test_ids_for_products.return_value = []  # No tests found

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.list_bugs(product_ids=[598])

    # Assert - empty result returned immediately
    assert result["bugs"] == []
    assert result["pagination"]["total_count"] == 0
    assert result["filters_applied"]["product_ids"] == [598]
    assert result["filters_applied"]["resolved_test_count"] == 0
    assert "warnings" in result

    # Assert - bug_repo NOT called when no tests
    mock_bug_repo.list_bugs.assert_not_called()


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_product_mode_custom_statuses() -> None:
    """Verify list_bugs accepts custom test statuses in product_ids mode."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    mock_test_repo.get_test_ids_for_products.return_value = [123]
    mock_test_repo.get_tests_cached_or_refresh.return_value = ({123: {}}, {"cache_hit_rate": 100.0})
    mock_bug_repo.list_bugs.return_value = ([], 0, [])

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.list_bugs(
        product_ids=[598],
        statuses=["locked", "archived"],
    )

    # Assert - custom statuses used
    call_kwargs = mock_test_repo.get_test_ids_for_products.call_args.kwargs
    assert call_kwargs["statuses"] == ["locked", "archived"]

    # Assert - statuses_applied shows custom statuses
    assert result["filters_applied"]["statuses_applied"] == ["locked", "archived"]


# =============================================================================
# list_bugs - Validation
# =============================================================================


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_raises_error_when_both_modes_specified() -> None:
    """Verify list_bugs raises ValidationError when both test_ids and product_ids."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]

    # Act & Assert
    with pytest.raises(ValidationError) as exc_info:
        await service.list_bugs(test_ids=[123], product_ids=[598])

    assert "test_ids" in exc_info.value.field
    assert "both" in exc_info.value.message.lower()


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_raises_error_when_neither_mode_specified() -> None:
    """Verify list_bugs raises ValidationError when neither test_ids nor product_ids."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]

    # Act & Assert
    with pytest.raises(ValidationError) as exc_info:
        await service.list_bugs()

    assert "test_ids" in exc_info.value.field
    msg = exc_info.value.message.lower()
    assert "either" in msg or "must be provided" in msg


# =============================================================================
# list_bugs - Warnings
# =============================================================================


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_warns_when_tests_not_found() -> None:
    """Verify list_bugs warns when some test_ids not found in cache."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()

    # Only test 123 found, 456 missing
    mock_test_repo.get_tests_cached_or_refresh.return_value = (
        {123: {"id": 123}},  # 456 not in dict
        {"cache_hit_rate": 100.0},
    )
    mock_bug_repo.list_bugs.return_value = ([], 0, [])

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.list_bugs(test_ids=[123, 456])

    # Assert - warning about missing tests
    assert "warnings" in result
    assert any("not found" in w.lower() for w in result["warnings"])
    assert any("456" in w for w in result["warnings"])


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_warns_on_stale_cache() -> None:
    """Verify list_bugs warns when cache hit rate is low (many API refreshes)."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()

    # Low cache hit rate
    mock_test_repo.get_tests_cached_or_refresh.return_value = (
        {123: {"id": 123}},
        {"cache_hit_rate": 20.0, "api_calls": 8},  # 80% stale
    )
    mock_bug_repo.list_bugs.return_value = ([], 0, [])

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.list_bugs(test_ids=[123])

    # Assert - warning about stale metadata
    assert "warnings" in result
    assert any("stale" in w.lower() or "refresh" in w.lower() for w in result["warnings"])


# =============================================================================
# list_bugs - Status Normalization
# =============================================================================


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_normalizes_open_status_to_forwarded() -> None:
    """Verify list_bugs normalizes 'open' status to 'forwarded' for DB queries."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    mock_test_repo.get_tests_cached_or_refresh.return_value = ({123: {}}, {"cache_hit_rate": 100.0})
    mock_bug_repo.list_bugs.return_value = ([], 0, [])

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    await service.list_bugs(test_ids=[123], status=["open"])

    # Assert - 'open' normalized to 'forwarded' in DB query
    call_kwargs = mock_bug_repo.list_bugs.call_args.kwargs
    assert call_kwargs["status"] == ["forwarded"]


# =============================================================================
# get_bug_summary - Existing Tests (unchanged)
# =============================================================================


@pytest.mark.unit
@pytest.mark.asyncio
async def test_get_bug_summary_returns_data() -> None:
    """Verify get_bug_summary returns bug data with data_as_of timestamp."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()

    bug_data = {
        "id": 12345,
        "title": "Login button not clickable",
        "severity": "critical",
        "status": "rejected",
        "known": False,
        "actual_result": "Button does not respond to clicks",
        "expected_result": "Button should navigate to dashboard",
        "steps": "1. Navigate to login page\n2. Click login button",
        "rejection_reason": "test_is_invalid",
        "reported_by_user": {"id": 123, "username": "john_doe"},
        "test": {"id": 109363, "title": "Homepage Navigation Test"},
        "feature": {"id": 456, "title": "User Login"},
        "reported_at": "2025-11-28T10:30:00+00:00",
    }

    mock_bug_repo.get_bug_by_id.return_value = bug_data

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.get_bug_summary(12345)

    # Assert
    mock_bug_repo.get_bug_by_id.assert_called_once_with(12345)
    assert result["id"] == 12345
    assert result["title"] == "Login button not clickable"
    assert result["severity"] == "critical"
    assert result["status"] == "rejected"
    assert result["known"] is False
    assert result["actual_result"] == "Button does not respond to clicks"
    assert result["expected_result"] == "Button should navigate to dashboard"
    assert result["steps"] == "1. Navigate to login page\n2. Click login button"
    assert result["rejection_reason"] == "test_is_invalid"
    assert result["reported_by_user"] == {"id": 123, "username": "john_doe"}
    assert result["test"] == {"id": 109363, "title": "Homepage Navigation Test"}
    assert result["feature"] == {"id": 456, "title": "User Login"}
    assert result["reported_at"] == "2025-11-28T10:30:00+00:00"

    # Verify data_as_of timestamp added (AC3)
    assert "data_as_of" in result
    data_as_of = datetime.fromisoformat(result["data_as_of"])
    now = datetime.now(UTC)
    # Timestamp should be within 1 second of now
    assert abs((now - data_as_of).total_seconds()) < 1


@pytest.mark.unit
@pytest.mark.asyncio
async def test_get_bug_summary_raises_not_found() -> None:
    """Verify get_bug_summary raises BugNotFoundException when bug not found."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    mock_bug_repo.get_bug_by_id.return_value = None  # Bug not found

    # Act & Assert
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]

    with pytest.raises(BugNotFoundException) as exc_info:
        await service.get_bug_summary(99999)

    assert exc_info.value.bug_id == 99999
    assert "99999" in str(exc_info.value)


@pytest.mark.unit
@pytest.mark.asyncio
async def test_get_bug_summary_with_null_fields() -> None:
    """Verify get_bug_summary handles NULL detail fields gracefully."""
    # Arrange
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()

    bug_data = {
        "id": 12345,
        "title": "Login button not clickable",
        "severity": None,  # NULL severity
        "status": None,  # NULL status
        "known": False,
        "actual_result": None,  # NULL
        "expected_result": None,  # NULL
        "steps": None,  # NULL
        "rejection_reason": None,  # No rejection
        "reported_by_user": None,  # No user
        "test": {"id": 109363, "title": "Homepage Test"},
        "feature": None,  # No feature
        "reported_at": None,  # NULL
    }

    mock_bug_repo.get_bug_by_id.return_value = bug_data

    # Act
    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.get_bug_summary(12345)

    # Assert - NULL fields should pass through (tool layer handles exclude_none)
    assert result["id"] == 12345
    assert result["severity"] is None
    assert result["actual_result"] is None
    assert result["reported_by_user"] is None
    assert result["feature"] is None
    assert result["test"] == {"id": 109363, "title": "Homepage Test"}
    assert "data_as_of" in result


# =============================================================================
# list_bugs - include parameter
# =============================================================================


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_include_passes_to_repo() -> None:
    """Verify include parameter is validated and passed to repository."""
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    mock_test_repo.get_tests_cached_or_refresh.return_value = (
        {123: {"id": 123, "status": "archived"}},
        {"cache_hit_rate": 100.0},
    )
    mock_bug_repo.list_bugs.return_value = ([], 0, [])

    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    await service.list_bugs(
        test_ids=[123],
        include=["actual_result", "steps"],
    )

    call_kwargs = mock_bug_repo.list_bugs.call_args.kwargs
    assert call_kwargs["include_fields"] == {"actual_result", "steps"}


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_include_none_passes_none_to_repo() -> None:
    """Verify include=None passes include_fields=None to repository."""
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    mock_test_repo.get_tests_cached_or_refresh.return_value = (
        {123: {"id": 123, "status": "archived"}},
        {"cache_hit_rate": 100.0},
    )
    mock_bug_repo.list_bugs.return_value = ([], 0, [])

    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    await service.list_bugs(test_ids=[123])

    call_kwargs = mock_bug_repo.list_bugs.call_args.kwargs
    assert call_kwargs["include_fields"] is None


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_include_invalid_field_raises_validation_error() -> None:
    """Verify include with invalid field raises ValidationError."""
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()

    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]

    with pytest.raises(ValidationError) as exc_info:
        await service.list_bugs(
            test_ids=[123],
            include=["actual_result", "invalid_field"],
        )

    assert "invalid_field" in exc_info.value.message
    assert exc_info.value.field == "include"


@pytest.mark.unit
@pytest.mark.asyncio
async def test_list_bugs_include_in_filters_applied() -> None:
    """Verify include is reflected in filters_applied."""
    mock_bug_repo = AsyncMock()
    mock_test_repo = AsyncMock()
    mock_test_repo.get_tests_cached_or_refresh.return_value = (
        {123: {"id": 123, "status": "archived"}},
        {"cache_hit_rate": 100.0},
    )
    mock_bug_repo.list_bugs.return_value = ([], 0, [])

    service = BugService(client=None, bug_repo=mock_bug_repo, test_repo=mock_test_repo)  # type: ignore[arg-type]
    result = await service.list_bugs(
        test_ids=[123],
        include=["actual_result", "expected_result"],
    )

    assert result["filters_applied"]["include"] == ["actual_result", "expected_result"]
