"""Bug service for bug listing and filtering operations.

This service handles business logic for bug queries, following
the service layer pattern (ADR-006). It is framework-agnostic and can
be used from MCP tools, REST APIs, CLI, or webhooks.

STORY-084: Created for list_bugs tool implementation.
STORY-XXX: Added product_ids support for product-wide bug queries.

Responsibilities:
- Bug listing and filtering (from SQLite)
- Pagination and sorting
- Filter combination (AND logic)
- Domain exception raising
- Test resolution from product_ids (when product_ids mode)

Does NOT handle:
- MCP protocol formatting
- User-facing error messages
- HTTP transport concerns
"""

import logging
from datetime import datetime
from typing import TYPE_CHECKING, Any

from testio_mcp.client import TestIOClient
from testio_mcp.exceptions import ValidationError
from testio_mcp.repositories.bug_repository import BugRepository
from testio_mcp.schemas.constants import EXECUTED_TEST_STATUSES
from testio_mcp.services.base_service import BaseService
from testio_mcp.transformers.bug_transformers import (
    normalize_bug_for_output,
    normalize_bug_list_for_output,
)
from testio_mcp.utilities.date_utils import parse_flexible_date
from testio_mcp.utilities.parsing import (
    normalize_bug_status_filter,
    normalize_testing_type_filter,
)

if TYPE_CHECKING:
    from testio_mcp.repositories.test_repository import TestRepository

logger = logging.getLogger(__name__)


class BugService(BaseService):
    """Business logic for bug operations.

    Uses repositories for data access.
    Handles filtering, pagination, and sorting.

    Example:
        ```python
        service = BugService(
            client=client,
            bug_repo=bug_repo,
            test_repo=test_repo
        )
        result = await service.list_bugs(
            test_ids=[123, 456],
            status=["rejected"],
            page=1,
            per_page=50
        )
        ```
    """

    __test__ = False

    def __init__(
        self,
        client: TestIOClient,
        bug_repo: BugRepository,
        test_repo: "TestRepository",
    ) -> None:
        """Initialize service with API client and repositories.

        Args:
            client: TestIO API client for making HTTP requests
            bug_repo: Repository for bug data access
            test_repo: Repository for test data access (to validate test_ids)
        """
        super().__init__(client)
        self.bug_repo = bug_repo
        self.test_repo = test_repo

    async def list_bugs(
        self,
        test_ids: list[int] | None = None,
        product_ids: list[int] | None = None,
        start_date: str | None = None,
        end_date: str | None = None,
        statuses: list[str] | None = None,
        testing_type: list[str] | None = None,
        status: list[str] | None = None,
        severity: list[str] | None = None,
        rejection_reason: list[str] | None = None,
        reported_by_user_id: int | None = None,
        feature_ids: list[int] | None = None,
        page: int = 1,
        per_page: int = 100,
        offset: int = 0,
        sort_by: str = "reported_at",
        sort_order: str = "desc",
        include: list[str] | None = None,
    ) -> dict[str, Any]:
        """List bugs for specified tests with filters, pagination, and sorting.

        Supports two modes:
        - test_ids mode: Query bugs for specific tests (original behavior)
        - product_ids mode: Resolve tests from products, then query bugs

        Returns bugs scoped to the specified tests. Combines multiple filters
        with AND logic.

        Staleness check: Refreshes test metadata (and cascades to bugs) if stale,
        using same pattern as query_metrics and get_product_quality_report.

        Args:
            test_ids: List of test identifiers (mutually exclusive with product_ids)
            product_ids: List of product identifiers (mutually exclusive with test_ids)
            start_date: Filter tests by end_at >= start_date (product_ids mode only)
            end_date: Filter tests by end_at <= end_date (product_ids mode only)
            statuses: Filter tests by status (product_ids mode only).
                Default: EXECUTED_TEST_STATUSES (excludes initialized, cancelled)
            testing_type: Filter tests by type (product_ids mode only)
            status: Filter bugs by bug status (single value or list)
            severity: Filter bugs by severity (single value or list)
            rejection_reason: Filter bugs by rejection reason (single value or list)
            reported_by_user_id: Filter bugs by reporting user ID
            feature_ids: Filter bugs by feature IDs (bugs reported for these features)
            page: Page number (1-indexed) for pagination
            per_page: Number of items per page (default: 100)
            offset: Starting offset for pagination (0-indexed)
            sort_by: Field to sort by (reported_at, severity, status, title)
            sort_order: Sort order (asc or desc)
            include: Optional list of detail fields to include in each bug.
                Allowed values: "actual_result", "expected_result", "steps".

        Returns:
            Dictionary with:
                - bugs: List of bug dictionaries with minimal fields
                - pagination: PaginationInfo with page metadata
                - filters_applied: Dictionary of applied filters for transparency
                - warnings: List of warning messages (if tests not found or stale)

        Raises:
            ValidationError: If both test_ids and product_ids are provided,
                or if neither is provided
        """
        ALLOWED_INCLUDE_FIELDS = {"actual_result", "expected_result", "steps"}

        warnings: list[str] = []

        # Validate include fields
        include_fields: set[str] | None = None
        if include:
            invalid = set(include) - ALLOWED_INCLUDE_FIELDS
            if invalid:
                raise ValidationError(
                    "include",
                    f"Invalid include fields: {sorted(invalid)}. "
                    f"Allowed: {sorted(ALLOWED_INCLUDE_FIELDS)}",
                )
            include_fields = set(include)

        # Validate mutual exclusivity: test_ids XOR product_ids
        if test_ids and product_ids:
            raise ValidationError(
                "test_ids",
                "Cannot use both test_ids and product_ids. Use one or the other.",
            )
        if not test_ids and not product_ids:
            raise ValidationError(
                "test_ids",
                "Either test_ids or product_ids must be provided.",
            )

        # Determine mode and resolve test_ids if needed
        is_product_mode = product_ids is not None
        resolved_test_ids: list[int]

        if is_product_mode:
            # Product mode: resolve test_ids from product_ids with filters
            resolved_test_ids = await self._resolve_tests_from_products(
                product_ids=product_ids,  # type: ignore[arg-type]
                start_date=start_date,
                end_date=end_date,
                statuses=statuses,
                testing_type=testing_type,
            )

            if not resolved_test_ids:
                # No tests found for products - return empty result
                return self._build_empty_result(
                    product_ids=product_ids,  # type: ignore[arg-type]
                    start_date=start_date,
                    end_date=end_date,
                    statuses=statuses,
                    testing_type=testing_type,
                    status=status,
                    severity=severity,
                    rejection_reason=rejection_reason,
                    reported_by_user_id=reported_by_user_id,
                    feature_ids=feature_ids,
                    page=page,
                    per_page=per_page,
                )
        else:
            # Test mode: use provided test_ids directly
            resolved_test_ids = test_ids  # type: ignore[assignment]

        # Normalize bug status filter: "open" → "forwarded" for DB queries
        # Users say "open bugs" but DB stores API value "forwarded"
        normalized_status = normalize_bug_status_filter(status)

        # Check staleness and refresh test metadata (cascades to bugs)
        # Same pattern as query_metrics and get_product_quality_report
        tests_dict, test_stats = await self.test_repo.get_tests_cached_or_refresh(resolved_test_ids)

        # Warn if tests not found (404 or never synced)
        found_test_ids = set(tests_dict.keys())
        missing_test_ids = set(resolved_test_ids) - found_test_ids
        if missing_test_ids and not is_product_mode:
            # Only warn in test_ids mode - in product mode, resolved tests should exist
            warnings.append(
                f"Warning: {len(missing_test_ids)} test(s) not found "
                f"(IDs: {sorted(missing_test_ids)}). "
                "These tests may not exist, may have been deleted, or haven't been synced yet. "
                "Use list_tests to see available tests."
            )

        # Warn if many tests needed refresh (stale metadata)
        if test_stats.get("cache_hit_rate", 100.0) < 50.0:
            api_calls = test_stats.get("api_calls", 0)
            warnings.append(
                f"Warning: {api_calls} test(s) had stale metadata and were refreshed from API."
            )

        # Delegate to repository for query (now safe - tests refreshed if needed)
        # Returns bugs, total_count, and unique_test_ids (for cross-filtering) in single query
        bugs, total_count, unique_test_ids = await self.bug_repo.list_bugs(
            test_ids=resolved_test_ids,
            status=normalized_status,  # Use normalized status for DB query
            severity=severity,
            rejection_reason=rejection_reason,
            reported_by_user_id=reported_by_user_id,
            feature_ids=feature_ids,
            page=page,
            per_page=per_page,
            offset=offset,
            sort_by=sort_by,
            sort_order=sort_order,
            primary_sort_by_test_end_at=is_product_mode,  # Two-level sort in product mode
            include_fields=include_fields,
        )

        # Calculate pagination metadata
        actual_offset = offset + (page - 1) * per_page
        start_index = actual_offset
        end_index = actual_offset + len(bugs) - 1 if bugs else -1
        has_more = actual_offset + len(bugs) < total_count

        # Build filters_applied for transparency
        filters_applied: dict[str, object] = {}

        if is_product_mode:
            # Product mode: show product_ids and test filters
            filters_applied["product_ids"] = product_ids
            # Show actual statuses applied (default or user-provided)
            filters_applied["statuses_applied"] = (
                statuses if statuses else list(EXECUTED_TEST_STATUSES)
            )
            if start_date:
                filters_applied["start_date"] = start_date
            if end_date:
                filters_applied["end_date"] = end_date
            if testing_type:
                filters_applied["testing_type"] = testing_type
            # Show resolved test count for transparency
            filters_applied["resolved_test_count"] = len(resolved_test_ids)
        else:
            # Test mode: show test_ids
            filters_applied["test_ids"] = test_ids

        # Bug filters (common to both modes)
        if status:
            filters_applied["status"] = status
        if severity:
            filters_applied["severity"] = severity
        if rejection_reason:
            filters_applied["rejection_reason"] = rejection_reason
        if reported_by_user_id:
            filters_applied["reported_by_user_id"] = reported_by_user_id
        if feature_ids:
            filters_applied["feature_ids"] = feature_ids
        if include:
            filters_applied["include"] = include

        # Warn if no bugs found for valid tests (could be legitimately empty)
        if total_count == 0 and found_test_ids:
            warnings.append(
                f"Info: No bugs found for {len(found_test_ids)} test(s). "
                "This could mean the tests have no bugs, or bugs haven't been synced yet."
            )

        result: dict[str, Any] = {
            "bugs": normalize_bug_list_for_output(bugs),
            "pagination": {
                "page": page,
                "per_page": per_page,
                "offset": actual_offset,
                "start_index": start_index,
                "end_index": end_index,
                "total_count": total_count,
                "has_more": has_more,
                # Cross-filtering support: unique test_ids across ALL matching bugs
                "unique_test_ids": unique_test_ids,
                "unique_test_count": len(unique_test_ids),
            },
            "filters_applied": filters_applied,
        }

        # Add warnings if any
        if warnings:
            result["warnings"] = warnings

        return result

    async def get_bug_summary(self, bug_id: int) -> dict[str, Any]:
        """Get bug summary with full details, related entities, and metadata.

        STORY-085: Added for get_bug_summary tool.

        SQLite-only (no API calls). Returns comprehensive bug information with:
        - Core fields (id, title, severity, status, known)
        - Detail fields (actual_result, expected_result, steps)
        - Rejection field (rejection_reason if rejected)
        - Related entities (reported_by_user, test, feature)
        - Metadata (reported_at, data_as_of)

        Args:
            bug_id: Bug identifier

        Returns:
            Bug summary dictionary with all fields per AC1-3

        Raises:
            BugNotFoundException: If bug not found

        Example:
            >>> summary = await service.get_bug_summary(12345)
            >>> print(summary["rejection_reason"])
            test_is_invalid
        """
        from datetime import UTC, datetime

        from testio_mcp.exceptions import BugNotFoundException

        # Delegate to repository
        bug_dict = await self.bug_repo.get_bug_by_id(bug_id)

        if not bug_dict:
            raise BugNotFoundException(bug_id)

        # Normalize status (forwarded → open) and add data_as_of timestamp
        normalized_bug = normalize_bug_for_output(bug_dict)
        return {
            **normalized_bug,
            "data_as_of": datetime.now(UTC).isoformat(),
        }

    async def _resolve_tests_from_products(
        self,
        product_ids: list[int],
        start_date: str | None = None,
        end_date: str | None = None,
        statuses: list[str] | None = None,
        testing_type: list[str] | None = None,
    ) -> list[int]:
        """Resolve test IDs from product IDs with optional filters.

        Follows same pattern as MultiTestReportService.get_product_quality_report().

        Args:
            product_ids: List of product identifiers
            start_date: Filter tests by end_at >= start_date
            end_date: Filter tests by end_at <= end_date
            statuses: Filter tests by status. Default: EXECUTED_TEST_STATUSES
            testing_type: Filter tests by type (coverage, focused, rapid, custom)

        Returns:
            List of test IDs sorted by end_at DESC (most recent first)
        """
        # Apply default statuses (exclude unexecuted tests)
        effective_statuses = statuses if statuses else list(EXECUTED_TEST_STATUSES)

        # Parse date filters
        parsed_start_date: datetime | None = None
        parsed_end_date: datetime | None = None

        if start_date:
            start_iso = parse_flexible_date(start_date, start_of_day=True)
            # parse_flexible_date returns "YYYY-MM-DD HH:MM:SS" format
            parsed_start_date = datetime.fromisoformat(start_iso)

        if end_date:
            end_iso = parse_flexible_date(end_date, start_of_day=False)
            parsed_end_date = datetime.fromisoformat(end_iso)

        # Normalize testing_type ("custom" -> "external")
        effective_testing_type = normalize_testing_type_filter(testing_type)

        # Resolve test IDs using TestRepository
        test_ids = await self.test_repo.get_test_ids_for_products(
            product_ids=product_ids,
            statuses=effective_statuses,
            start_date=parsed_start_date,
            end_date=parsed_end_date,
            date_field="end_at",  # Filter by test completion date
            testing_type=effective_testing_type,
        )

        logger.debug(
            f"Resolved {len(test_ids)} tests from {len(product_ids)} products "
            f"(statuses={effective_statuses}, "
            f"start_date={start_date}, end_date={end_date}, "
            f"testing_type={testing_type})"
        )

        return test_ids

    def _build_empty_result(
        self,
        product_ids: list[int],
        start_date: str | None,
        end_date: str | None,
        statuses: list[str] | None,
        testing_type: list[str] | None,
        status: list[str] | None,
        severity: list[str] | None,
        rejection_reason: list[str] | None,
        reported_by_user_id: int | None,
        feature_ids: list[int] | None,
        page: int,
        per_page: int,
    ) -> dict[str, Any]:
        """Build empty result when no tests found for products.

        Used when product_ids mode resolves to zero tests.

        Args:
            product_ids: List of product identifiers
            start_date: Start date filter (for filters_applied)
            end_date: End date filter (for filters_applied)
            statuses: Test statuses filter (for filters_applied)
            testing_type: Testing type filter (for filters_applied)
            status: Bug status filter (for filters_applied)
            severity: Bug severity filter (for filters_applied)
            rejection_reason: Rejection reason filter (for filters_applied)
            reported_by_user_id: Reporter user ID filter (for filters_applied)
            feature_ids: Feature IDs filter (for filters_applied)
            page: Page number (for pagination)
            per_page: Items per page (for pagination)

        Returns:
            Empty result dictionary with pagination and filters_applied
        """
        # Build filters_applied for transparency
        filters_applied: dict[str, object] = {
            "product_ids": product_ids,
            "statuses_applied": statuses if statuses else list(EXECUTED_TEST_STATUSES),
            "resolved_test_count": 0,
        }

        if start_date:
            filters_applied["start_date"] = start_date
        if end_date:
            filters_applied["end_date"] = end_date
        if testing_type:
            filters_applied["testing_type"] = testing_type
        if status:
            filters_applied["status"] = status
        if severity:
            filters_applied["severity"] = severity
        if rejection_reason:
            filters_applied["rejection_reason"] = rejection_reason
        if reported_by_user_id:
            filters_applied["reported_by_user_id"] = reported_by_user_id
        if feature_ids:
            filters_applied["feature_ids"] = feature_ids

        return {
            "bugs": [],
            "pagination": {
                "page": page,
                "per_page": per_page,
                "offset": 0,
                "start_index": 0,
                "end_index": -1,
                "total_count": 0,
                "has_more": False,
                "unique_test_ids": [],
                "unique_test_count": 0,
            },
            "filters_applied": filters_applied,
            "warnings": [
                "Info: No tests found matching the specified filters. "
                "Try adjusting date range, statuses, or testing_type."
            ],
        }
