"""Book resolver - orchestrates metadata lookups across multiple sources."""

from __future__ import annotations

import asyncio
import logging
import re
import time
from collections.abc import Sequence
from enum import Enum
from typing import TYPE_CHECKING, Any

from book_match.core.exceptions import SourceError, SourceRateLimitError
from book_match.core.types import (
    Book,
    MatchResult,
    ResolveOutcome,
    SearchQuery,
    SourceDiagnostic,
    SourceStatus,
)
from book_match.isbn.normalize import normalize_isbn
from book_match.matching.engine import BookMatcher

logger = logging.getLogger(__name__)

# Pattern to match API keys in URLs (key=VALUE or api_key=VALUE)
_SENSITIVE_PARAM_PATTERN = re.compile(
    r"([?&](?:key|api_key|apikey|token|access_token)=)[^&\s]+",
    re.IGNORECASE,
)


def _sanitize_error(error: Exception) -> str:
    """Sanitize error messages by redacting sensitive URL parameters."""
    msg = str(error)
    return _SENSITIVE_PARAM_PATTERN.sub(r"\1[REDACTED]", msg)


if TYPE_CHECKING:
    from collections.abc import Callable

    from book_match.core.config import MatchConfig
    from book_match.sources.base import MetadataSource


class ResolveStrategy(Enum):
    """Strategy for resolving matches from multiple sources."""

    BEST_MATCH = "best_match"  # Return highest confidence match across all sources
    FIRST_CONFIDENT = "first_confident"  # Return first match above threshold
    ALL_SOURCES = "all_sources"  # Return best match from each source
    CONSENSUS = "consensus"  # Require multiple sources to agree


class BookResolver:
    """Orchestrates metadata lookups across multiple sources.

    The resolver queries multiple metadata sources, matches results against
    the input book, and returns ranked candidates.

    Example:
        >>> resolver = BookResolver(
        ...     sources=[OpenLibrarySource(), GoogleBooksSource()],
        ... )
        >>> results = await resolver.resolve(my_book)
        >>> for result in results:
        ...     print(f"{result.confidence:.0%}: {result.remote_book.title}")
    """

    def __init__(
        self,
        sources: Sequence[MetadataSource],
        matcher: BookMatcher | None = None,
        match_config: MatchConfig | None = None,
        strategy: ResolveStrategy = ResolveStrategy.BEST_MATCH,
        min_agreeing_sources: int = 2,
    ) -> None:
        """Initialize the resolver.

        Args:
            sources: List of metadata sources to query
            matcher: BookMatcher instance (created with match_config if not provided)
            match_config: Configuration for matching (ignored if matcher provided)
            strategy: Resolution strategy
            min_agreeing_sources: Minimum sources that must agree for CONSENSUS strategy
        """
        if not sources:
            raise ValueError("At least one metadata source is required")

        self.sources = list(sources)
        self.matcher = matcher or BookMatcher(match_config)
        self.strategy = strategy
        self.min_agreeing_sources = min_agreeing_sources

        if self.strategy == ResolveStrategy.CONSENSUS:
            num_sources = len(self.sources)
            if min_agreeing_sources < 2:
                raise ValueError(
                    "min_agreeing_sources must be at least 2 when using the "
                    "CONSENSUS resolve strategy"
                )
            if min_agreeing_sources > num_sources:
                raise ValueError(
                    "min_agreeing_sources cannot exceed the number of configured "
                    f"sources (got {min_agreeing_sources}, have {num_sources})"
                )

    def _apply_strategy(
        self,
        results: list[MatchResult],
        all_candidates: list[Book],
    ) -> list[MatchResult]:
        """Apply the configured resolution strategy to filter/sort results."""
        if self.strategy == ResolveStrategy.FIRST_CONFIDENT:
            for result in results:
                if result.should_auto_accept:
                    return [result]
            return []  # No confident match found

        elif self.strategy == ResolveStrategy.ALL_SOURCES:
            best_by_source: dict[str, MatchResult] = {}
            for result in results:
                source = result.remote_book.source
                if source:
                    if source not in best_by_source:
                        best_by_source[source] = result
                    elif result.confidence > best_by_source[source].confidence:
                        best_by_source[source] = result
            return sorted(
                best_by_source.values(),
                key=lambda r: r.confidence,
                reverse=True,
            )

        elif self.strategy == ResolveStrategy.CONSENSUS:
            return self._apply_consensus(results, all_candidates)

        # BEST_MATCH: just return results as-is (already sorted by confidence)
        return results

    async def _query_source(
        self,
        source: MetadataSource,
        query: SearchQuery,
        limit: int,
    ) -> list[Book]:
        """Query a single source with error handling."""
        try:
            return await source.search(query, limit=limit)
        except SourceError as e:
            logger.warning("Source '%s' query failed: %s", source.name, _sanitize_error(e))
            return []
        except Exception:
            logger.error("Unexpected error querying source '%s'", source.name, exc_info=True)
            return []

    async def resolve(
        self,
        book: Book,
        min_confidence: float = 0.5,
        max_results: int = 10,
        query_limit: int = 10,
    ) -> list[MatchResult]:
        """Resolve a book against all sources.

        Args:
            book: Book to find matches for
            min_confidence: Minimum confidence to include
            max_results: Maximum total results to return
            query_limit: Maximum results per source query

        Returns:
            List of MatchResults, sorted by confidence
        """
        query = SearchQuery.from_book(book)

        if query.is_empty:
            return []

        # Query all sources concurrently
        tasks = [self._query_source(source, query, query_limit) for source in self.sources]
        source_results = await asyncio.gather(*tasks)

        # Collect all candidates
        all_candidates: list[Book] = []
        for candidates in source_results:
            all_candidates.extend(candidates)

        if not all_candidates:
            return []

        # Match against all candidates
        results = self.matcher.match_many(
            book,
            all_candidates,
            min_confidence=min_confidence,
        )

        # Apply strategy
        results = self._apply_strategy(results, all_candidates)

        return results[:max_results]

    def _apply_consensus(
        self,
        results: list[MatchResult],
        all_candidates: list[Book],
    ) -> list[MatchResult]:
        """Filter results to only those confirmed by multiple sources.

        Groups candidates by identity (using BookMatcher cross-comparison),
        then keeps only groups that have candidates from at least
        ``min_agreeing_sources`` distinct sources. Falls back to BEST_MATCH
        behavior if fewer than 2 sources returned results.
        """
        # Determine how many distinct sources returned results
        sources_with_results = {c.source for c in all_candidates if c.source}
        if len(sources_with_results) < 2:
            # Not enough sources to apply consensus — fall back to BEST_MATCH
            return results

        # Group candidates by identity using full match() to avoid
        # quick_score's ISBN short-circuit missing title/author matches.
        groups: list[list[Book]] = []
        for candidate in all_candidates:
            placed = False
            for group in groups:
                result = self.matcher.match(group[0], candidate)
                if result.confidence >= 0.80:
                    group.append(candidate)
                    placed = True
                    break
            if not placed:
                groups.append([candidate])

        # Find candidates that appear in enough distinct sources.
        # Book is a frozen dataclass (hashable), so use set[Book] directly.
        consensus_candidates: set[Book] = set()
        for group in groups:
            distinct_sources = {book.source for book in group if book.source}
            if len(distinct_sources) >= self.min_agreeing_sources:
                for book in group:
                    consensus_candidates.add(book)

        # Filter results to only consensus candidates
        filtered = [r for r in results if r.remote_book in consensus_candidates]
        return filtered

    async def _query_source_with_diagnostic(
        self,
        source: MetadataSource,
        query: SearchQuery,
        limit: int,
    ) -> tuple[list[Book], SourceDiagnostic]:
        """Query a single source and return results with diagnostic info."""
        start = time.monotonic()
        try:
            results = await source.search(query, limit=limit)
            elapsed = (time.monotonic() - start) * 1000
            return results, SourceDiagnostic(
                source_name=source.name,
                status=SourceStatus.SUCCESS,
                result_count=len(results),
                duration_ms=elapsed,
            )
        except TimeoutError as e:
            elapsed = (time.monotonic() - start) * 1000
            return [], SourceDiagnostic(
                source_name=source.name,
                status=SourceStatus.TIMEOUT,
                result_count=0,
                duration_ms=elapsed,
                error_message=_sanitize_error(e),
            )
        except SourceRateLimitError as e:
            elapsed = (time.monotonic() - start) * 1000
            return [], SourceDiagnostic(
                source_name=source.name,
                status=SourceStatus.RATE_LIMITED,
                result_count=0,
                duration_ms=elapsed,
                error_message=_sanitize_error(e),
            )
        except Exception as e:
            elapsed = (time.monotonic() - start) * 1000
            return [], SourceDiagnostic(
                source_name=source.name,
                status=SourceStatus.ERROR,
                result_count=0,
                duration_ms=elapsed,
                error_message=_sanitize_error(e),
            )

    async def resolve_with_diagnostics(
        self,
        book: Book,
        min_confidence: float = 0.5,
        max_results: int = 10,
        query_limit: int = 10,
    ) -> ResolveOutcome:
        """Resolve a book against all sources, returning per-source diagnostics.

        Same logic as resolve() but wraps results in a ResolveOutcome that
        includes diagnostic information for each source query.

        Args:
            book: Book to find matches for
            min_confidence: Minimum confidence to include
            max_results: Maximum total results to return
            query_limit: Maximum results per source query

        Returns:
            ResolveOutcome with results and source diagnostics
        """
        query = SearchQuery.from_book(book)

        if query.is_empty:
            return ResolveOutcome(results=(), source_diagnostics=())

        tasks = [
            self._query_source_with_diagnostic(source, query, query_limit)
            for source in self.sources
        ]
        outcomes = await asyncio.gather(*tasks)

        all_candidates: list[Book] = []
        diagnostics: list[SourceDiagnostic] = []
        for candidates, diagnostic in outcomes:
            all_candidates.extend(candidates)
            diagnostics.append(diagnostic)

        if not all_candidates:
            return ResolveOutcome(results=(), source_diagnostics=tuple(diagnostics))

        results = self.matcher.match_many(book, all_candidates, min_confidence=min_confidence)

        # Apply the configured strategy
        results = self._apply_strategy(results, all_candidates)

        return ResolveOutcome(
            results=tuple(results[:max_results]),
            source_diagnostics=tuple(diagnostics),
        )

    async def resolve_by_isbn(
        self,
        isbn: str,
        min_confidence: float = 0.5,
    ) -> list[MatchResult]:
        """Resolve a book by ISBN only.

        This is faster than full resolve when you have an ISBN.

        Args:
            isbn: ISBN-10 or ISBN-13
            min_confidence: Minimum confidence threshold

        Returns:
            List of MatchResults
        """
        # Normalize ISBN (strip hyphens/spaces) before classifying
        clean_isbn = normalize_isbn(isbn) or isbn
        book = Book(
            isbn_10=clean_isbn if len(clean_isbn) == 10 else None,
            isbn_13=clean_isbn if len(clean_isbn) == 13 else None,
        )

        # Query sources for ISBN
        tasks = [source.fetch_by_isbn(clean_isbn) for source in self.sources]
        source_results = await asyncio.gather(*tasks, return_exceptions=True)

        candidates = []
        for source, result in zip(self.sources, source_results, strict=True):
            if isinstance(result, Exception):
                logger.warning(
                    "Source '%s' fetch_by_isbn failed for ISBN %s: %s",
                    source.name,
                    clean_isbn,
                    _sanitize_error(result),
                )
            elif isinstance(result, Book) and result is not None:
                candidates.append(result)

        if not candidates:
            return []

        return self.matcher.match_many(book, candidates, min_confidence=min_confidence)

    async def resolve_batch(
        self,
        books: Sequence[Book],
        min_confidence: float = 0.5,
        max_results_per_book: int = 5,
        on_progress: Callable[[int, int], None] | None = None,
        concurrency: int = 5,
    ) -> dict[int, list[MatchResult]]:
        """Resolve multiple books concurrently.

        Args:
            books: Books to resolve
            min_confidence: Minimum confidence threshold
            max_results_per_book: Max results per book
            on_progress: Callback with (completed, total)
            concurrency: Maximum concurrent resolutions

        Returns:
            Dict mapping book index to list of matches
        """
        results: dict[int, list[MatchResult]] = {}
        semaphore = asyncio.Semaphore(concurrency)
        completed = 0

        async def resolve_one(idx: int, book: Book) -> None:
            nonlocal completed
            async with semaphore:
                matches = await self.resolve(
                    book,
                    min_confidence=min_confidence,
                    max_results=max_results_per_book,
                )
                results[idx] = matches
                completed += 1
                if on_progress:
                    on_progress(completed, len(books))

        tasks = [resolve_one(i, book) for i, book in enumerate(books)]
        await asyncio.gather(*tasks)

        return results

    async def close(self) -> None:
        """Close all source connections."""
        for source in self.sources:
            if hasattr(source, "close"):
                result = source.close()
                if asyncio.iscoroutine(result) or asyncio.isfuture(result):
                    await result

    async def __aenter__(self) -> BookResolver:
        return self

    async def __aexit__(self, *args: Any) -> None:
        await self.close()
