"""
Stage 2: LLM Classification

Classify normalized reviews into URT codes with span-level extraction.

Responsibilities:
- Call LLM for span extraction and classification (batched for efficiency)
- Generate embeddings
- Calculate trust scores
- Select primary span
- Write to reviews_enriched and review_spans tables

Efficiency Features:
- Batch processing: Multiple reviews per LLM call (configurable batch_size)
- Prompt caching: System prompt cached to reduce input token costs
- Parallel execution: Multiple batches processed concurrently
"""

from __future__ import annotations

import asyncio
import hashlib
import logging
import re
import uuid
from typing import TYPE_CHECKING, Any

from reviewiq_pipeline.contracts import (
    ClassifiedReview,
    ExtractedSpan,
    ReviewToClassify,
    Stage2Input,
    Stage2Output,
    Stage2Stats,
)
from reviewiq_pipeline.services.llm_client import (
    LLMClient,
    create_fallback_response,
    BatchReviewInput,
    BatchSizer,
    PartialBatchResult,
)
from reviewiq_pipeline.services.prompt_builder import PromptBuilder
from reviewiq_pipeline.services.classification_validator import validate_classification
from reviewiq_pipeline.services.review_router import (
    ReviewRouter,
    RoutingTier,
    create_router,
)

if TYPE_CHECKING:
    from reviewiq_pipeline.config import Config
    from reviewiq_pipeline.contracts import LLMClassificationResponse, LLMSpanResponse
    from reviewiq_pipeline.db.connection import DatabasePool
    from reviewiq_pipeline.db.repositories import ReviewRepository, SpanRepository
    from reviewiq_pipeline.services.embeddings import EmbeddingService
    from reviewiq_pipeline.services.llm_client import LLMClientBase

logger = logging.getLogger(__name__)

# URT code validation pattern
URT_CODE_PATTERN = re.compile(r"^[OPJEAVR][1-4]\.[0-9]{2}$")

# Valence priority for primary span selection (lower = higher priority)
VALENCE_PRIORITY = {"V-": 0, "V±": 1, "V0": 2, "V+": 3}

# Intensity priority (lower = higher priority for I3)
INTENSITY_PRIORITY = {"I3": 0, "I2": 1, "I1": 2}


class Stage2Classifier:
    """
    Stage 2: Classify reviews using LLM and extract spans.

    This stage:
    1. Calls LLM to extract and classify spans
    2. Generates embeddings for each review
    3. Calculates trust scores
    4. Selects primary span
    5. Writes classification results to database
    """

    def __init__(
        self,
        config: Config,
        db: DatabasePool | None = None,
        review_repo: ReviewRepository | None = None,
        span_repo: SpanRepository | None = None,
        embedding_service: EmbeddingService | None = None,
    ):
        self.config = config
        self.db = db
        self.review_repo = review_repo
        self.span_repo = span_repo
        self.embedding_service = embedding_service
        self._llm_client: LLMClientBase | None = None
        self._cheap_llm_client: LLMClientBase | None = None  # For CHEAP tier
        self._prompt_builder: PromptBuilder | None = None
        self._batch_sizer: BatchSizer | None = None
        self._system_prompt_tokens: int = 0

        # Initialize router if enabled
        self._router: ReviewRouter | None = None
        if config.router_enabled:
            self._router = create_router(
                conservative=config.router_conservative,
                skip_enabled=config.router_skip_enabled,
                cheap_model_enabled=config.router_cheap_model_enabled,
            )
            logger.info(
                f"Review router enabled: conservative={config.router_conservative}, "
                f"skip={config.router_skip_enabled}, cheap={config.router_cheap_model_enabled}"
            )

    async def _get_llm_client(self) -> LLMClientBase:
        """Get or create LLM client with dynamic prompt from database."""
        if self._llm_client is None:
            self._llm_client = LLMClient.create(self.config)

            # Build prompt dynamically from database if available
            batch_prompt = None
            if self.db and self.db.pool:
                try:
                    self._prompt_builder = PromptBuilder(self.db.pool)
                    # Build both single and batch prompts
                    single_prompt = await self._prompt_builder.build(batch_mode=False)
                    batch_prompt = await self._prompt_builder.build(batch_mode=True)
                    self._llm_client.set_prompt(single_prompt, batch_prompt)
                    logger.info("Using dynamic prompts from database taxonomy (single + batch)")
                except Exception as e:
                    logger.warning(f"Failed to build dynamic prompt, using static: {e}")

            # Estimate system prompt tokens for batch sizing
            prompt_for_sizing = batch_prompt or self._llm_client.get_prompt(batch_mode=True)
            self._system_prompt_tokens = len(prompt_for_sizing) // 4  # ~4 chars per token

            # Initialize batch sizer
            self._batch_sizer = BatchSizer(
                model=self.config.llm_model,
                system_prompt_tokens=self._system_prompt_tokens,
                target_utilization=self.config.classification_target_utilization,
            )
            logger.info(
                f"BatchSizer initialized: model={self.config.llm_model}, "
                f"system_prompt_tokens≈{self._system_prompt_tokens}, "
                f"target_utilization={self.config.classification_target_utilization:.0%}"
            )

        return self._llm_client

    async def _get_cheap_llm_client(self) -> LLMClientBase:
        """Get or create cheap LLM client for CHEAP tier routing."""
        if self._cheap_llm_client is None:
            # Create a copy of config with cheap model
            from copy import copy
            cheap_config = copy(self.config)
            cheap_config.llm_model = self.config.router_cheap_model

            self._cheap_llm_client = LLMClient.create(cheap_config)

            # Use same prompts as main client
            if self._llm_client:
                single_prompt = self._llm_client.get_prompt(batch_mode=False)
                batch_prompt = self._llm_client.get_prompt(batch_mode=True)
                self._cheap_llm_client.set_prompt(single_prompt, batch_prompt)

            logger.info(f"Cheap LLM client initialized with model: {self.config.router_cheap_model}")

        return self._cheap_llm_client

    async def close(self) -> None:
        """Close resources."""
        if self._llm_client:
            await self._llm_client.close()
            self._llm_client = None
        if self._cheap_llm_client:
            await self._cheap_llm_client.close()
            self._cheap_llm_client = None

    async def process(self, input_data: Stage2Input) -> Stage2Output:
        """
        Process reviews through classification stage using batched LLM calls.

        This method:
        1. Routes reviews to appropriate tier (if router enabled)
        2. Calculates optimal batch size based on context window and review lengths
        3. Splits reviews into batches dynamically
        4. Processes batches in parallel (with concurrency limit)
        5. Uses prompt caching to reduce costs on subsequent batches
        6. Adapts batch size based on actual token usage

        Args:
            input_data: Stage 2 input with reviews and config

        Returns:
            Stage2Output with classified reviews and stats
        """
        batch_id = str(uuid.uuid4())[:8]
        reviews = input_data["reviews"]
        max_concurrent = self.config.classification_max_concurrent
        fixed_batch_size = self.config.classification_batch_size  # 0 = auto

        llm_client = await self._get_llm_client()

        # Smart routing (if enabled)
        skip_classified: list[ClassifiedReview] = []
        reviews_to_process = reviews
        cheap_reviews: list[ReviewToClassify] = []
        full_reviews: list[ReviewToClassify] = []

        if self._router:
            routed = self._router.route_batch(reviews)

            # Process SKIP tier immediately (no LLM)
            for review in routed[RoutingTier.SKIP]:
                routing = review.get("_routing")
                if routing and routing.skip_classification:
                    classified = self._create_skip_classification(
                        review,
                        routing.skip_classification,
                        batch_id,
                    )
                    skip_classified.append(classified)

                    # Persist if configured
                    if self.review_repo and self.span_repo:
                        await self._persist_classification(
                            classified, review, batch_id, input_data["config"]
                        )

            cheap_reviews = routed[RoutingTier.CHEAP_MODEL]
            full_reviews = routed[RoutingTier.FULL_MODEL]

            router_stats = self._router.get_stats()
            logger.info(
                f"Router results: SKIP={len(routed[RoutingTier.SKIP])}, "
                f"CHEAP={len(cheap_reviews)}, FULL={len(full_reviews)}"
            )

            # If no cheap model enabled, merge into full
            if not self.config.router_cheap_model_enabled:
                full_reviews = cheap_reviews + full_reviews
                cheap_reviews = []
        else:
            # No router - all reviews go to full model
            full_reviews = reviews

        # Calculate optimal batch size dynamically (based on full_reviews)
        all_llm_reviews = full_reviews + cheap_reviews  # Combined for batch sizing
        if all_llm_reviews:
            review_dicts = [{"text": r["text"]} for r in all_llm_reviews]
            batch_calc = self._batch_sizer.calculate_batch_size(
                reviews=review_dicts,
                fixed_size=fixed_batch_size if fixed_batch_size > 0 else None,
            )
            batch_size = batch_calc.batch_size
            logger.info(f"Batch sizing: {batch_calc.reasoning}")
        else:
            batch_size = fixed_batch_size or 25

        llm_review_count = len(full_reviews) + len(cheap_reviews)
        logger.info(
            f"Stage 2: Classifying {len(reviews)} reviews "
            f"(batch_id={batch_id}, batch_size={batch_size}, max_concurrent={max_concurrent}, "
            f"skip={len(skip_classified)}, llm={llm_review_count})"
        )

        # Split FULL tier reviews into batches
        full_batches = [
            full_reviews[i:i + batch_size]
            for i in range(0, len(full_reviews), batch_size)
        ] if full_reviews else []

        # Split CHEAP tier reviews into batches
        cheap_batches = [
            cheap_reviews[i:i + batch_size]
            for i in range(0, len(cheap_reviews), batch_size)
        ] if cheap_reviews else []

        logger.info(
            f"Split into {len(full_batches)} FULL batches + {len(cheap_batches)} CHEAP batches "
            f"({'unlimited' if max_concurrent == 0 else max_concurrent} concurrent)"
        )

        # Process batches - unlimited concurrency by default (0 = no limit)
        semaphore = asyncio.Semaphore(max_concurrent) if max_concurrent > 0 else None
        total_tokens = 0
        total_cost = 0.0
        total_cached_tokens = 0
        classified_reviews: list[ClassifiedReview] = []
        error_count = 0

        # Get cheap client if needed
        cheap_client = None
        if cheap_batches:
            cheap_client = await self._get_cheap_llm_client()

        async def process_batch(
            batch_reviews: list[ReviewToClassify],
            batch_num: int,
            client: LLMClientBase,
            tier_label: str = "FULL",
        ):
            """Process a single batch of reviews."""

            async def do_batch():
                nonlocal total_tokens, total_cost, total_cached_tokens, error_count
                try:
                    batch_classified, batch_metadata = await self._classify_batch(
                        batch_reviews,
                        input_data["config"]["profile"],
                        client,
                        batch_id,
                        input_data["config"],
                    )

                    batch_tokens = batch_metadata.get("total_tokens", 0)
                    batch_cost = batch_metadata.get("cost_usd", 0.0)
                    batch_cached = batch_metadata.get("cached_tokens", 0)

                    total_tokens += batch_tokens
                    total_cost += batch_cost
                    total_cached_tokens += batch_cached

                    # Update batch sizer with actual token usage for adaptive sizing
                    if self._batch_sizer:
                        input_tokens = batch_metadata.get("input_tokens", 0)
                        output_tokens = batch_metadata.get("output_tokens", 0)
                        self._batch_sizer.update_from_response(
                            batch_size=len(batch_reviews),
                            input_tokens=input_tokens - self._system_prompt_tokens,  # Exclude system prompt
                            output_tokens=output_tokens,
                        )

                    total_batches = len(full_batches) + len(cheap_batches)
                    logger.info(
                        f"[{tier_label}] Batch {batch_num}/{total_batches}: "
                        f"{len(batch_classified)} reviews, "
                        f"{batch_tokens:,} tokens ({batch_cached:,} cached), "
                        f"${batch_cost:.4f}"
                    )

                    return batch_classified

                except PartialBatchResult as e:
                    # Partial success - we recovered some reviews
                    logger.info(
                        f"Batch {batch_num} partial success: {len(e.partial_results)} recovered, "
                        f"{len(e.missing_indices)} need reprocessing"
                    )

                    # Process the recovered results
                    partial_classified: list[ClassifiedReview] = []
                    profile = input_data["config"]["profile"]

                    for partial_review in e.partial_results:
                        idx = partial_review.get("review_index", -1)
                        if 0 <= idx < len(batch_reviews):
                            review = batch_reviews[idx]
                            try:
                                classified = self._process_llm_response(
                                    review,
                                    {
                                        "spans": partial_review.get("spans", []),
                                        "review_summary": partial_review.get("review_summary", {}),
                                    },
                                    profile,
                                    batch_id,
                                    is_fallback=False,
                                )
                                partial_classified.append(classified)

                                if self.review_repo and self.span_repo:
                                    await self._persist_classification(
                                        classified, review, batch_id, input_data["config"]
                                    )
                            except Exception as pe:
                                logger.warning(f"Error processing recovered review {idx}: {pe}")
                                e.missing_indices.append(idx)

                    # Update cost tracking from partial metadata
                    if e.metadata:
                        total_tokens += e.metadata.get("total_tokens", 0)
                        total_cost += e.metadata.get("cost_usd", 0.0)
                        total_cached_tokens += e.metadata.get("cached_tokens", 0)

                    # Only fallback process the missing reviews
                    if e.missing_indices:
                        missing_reviews = [batch_reviews[i] for i in e.missing_indices if 0 <= i < len(batch_reviews)]
                        error_count += len(missing_reviews)
                        logger.info(f"Reprocessing {len(missing_reviews)} missing reviews individually")
                        fallback_results = await self._fallback_individual_processing(
                            missing_reviews,
                            input_data["config"]["profile"],
                            client,  # Use same client as batch
                            batch_id,
                            input_data["config"],
                        )
                        partial_classified.extend(fallback_results)

                    return partial_classified

                except Exception as e:
                    logger.error(f"[{tier_label}] Batch {batch_num} failed: {e}", exc_info=True)
                    error_count += len(batch_reviews)
                    # Fallback: process individually
                    return await self._fallback_individual_processing(
                        batch_reviews,
                        input_data["config"]["profile"],
                        client,  # Use same client as batch
                        batch_id,
                        input_data["config"],
                    )

            # Run with or without semaphore
            if semaphore:
                async with semaphore:
                    return await do_batch()
            else:
                return await do_batch()

        # Process all batches concurrently (both FULL and CHEAP tiers)
        all_batch_tasks = []

        # FULL tier batches
        for i, batch in enumerate(full_batches):
            all_batch_tasks.append(
                process_batch(batch, i + 1, llm_client, "FULL")
            )

        # CHEAP tier batches
        for i, batch in enumerate(cheap_batches):
            all_batch_tasks.append(
                process_batch(batch, len(full_batches) + i + 1, cheap_client, "CHEAP")
            )

        batch_results = await asyncio.gather(*all_batch_tasks) if all_batch_tasks else []

        # Flatten results from LLM processing
        for batch_result in batch_results:
            classified_reviews.extend(batch_result)

        # Add skip-classified reviews (no LLM)
        classified_reviews.extend(skip_classified)

        # Calculate stats
        total_spans = sum(len(r.get("spans", [])) for r in classified_reviews)
        avg_spans = total_spans / len(classified_reviews) if classified_reviews else 0

        # Log final statistics
        skip_count = len(skip_classified)
        llm_count = len(classified_reviews) - skip_count
        logger.info(
            f"Stage 2 complete: {len(classified_reviews)} classified "
            f"(LLM={llm_count}, skipped={skip_count}), "
            f"{error_count} errors, {total_spans} spans total, "
            f"${total_cost:.4f} cost, {total_cached_tokens:,} cached tokens"
        )

        if self._batch_sizer:
            stats = self._batch_sizer.get_stats_summary()
            logger.info(
                f"Batch sizing stats: "
                f"avg_input={stats['avg_input_tokens']} tokens/review, "
                f"avg_output={stats['avg_output_tokens']} tokens/review, "
                f"range=[{stats['min_review_tokens']}-{stats['max_review_tokens']}]"
            )

        return Stage2Output(
            batch_id=batch_id,
            taxonomy_version=input_data["config"]["taxonomy_version"],
            model_version=self.config.llm_model,
            prompt_version="v2.0-batched",
            reviews_classified=classified_reviews,
            stats=Stage2Stats(
                input_count=len(reviews),
                success_count=len(classified_reviews),
                error_count=error_count,
                total_spans=total_spans,
                avg_spans_per_review=avg_spans,
                llm_tokens_used=total_tokens,
                llm_cost_usd=total_cost,
            ),
        )

    async def _classify_batch(
        self,
        reviews: list[ReviewToClassify],
        profile: str,
        llm_client: LLMClientBase,
        batch_id: str,
        config: dict[str, Any],
    ) -> tuple[list[ClassifiedReview], dict[str, Any]]:
        """
        Classify a batch of reviews in a single LLM call.

        Args:
            reviews: List of reviews to classify
            profile: Classification profile
            llm_client: LLM client instance
            batch_id: Batch identifier
            config: Classification config

        Returns:
            Tuple of (list of classified reviews, aggregated metadata)
        """
        # Prepare batch input
        batch_input: list[BatchReviewInput] = [
            BatchReviewInput(
                review_id=r["review_id"],
                text=r["text"],
                rating=r["rating"],
            )
            for r in reviews
        ]

        # Call LLM for batch classification
        llm_responses, metadata = await llm_client.classify_batch(batch_input, profile)

        # Process each response
        classified_reviews: list[ClassifiedReview] = []

        for i, (review, llm_response) in enumerate(zip(reviews, llm_responses)):
            try:
                classified = self._process_llm_response(
                    review,
                    llm_response,
                    profile,
                    batch_id,
                    is_fallback=False,
                )
                classified_reviews.append(classified)

                # Persist to database if configured
                if self.review_repo and self.span_repo:
                    await self._persist_classification(
                        classified,
                        review,
                        batch_id,
                        config,
                    )

            except Exception as e:
                logger.warning(f"Error processing review {review['review_id']}: {e}")
                # Use fallback for this review
                fallback = create_fallback_response(review["text"])
                classified = self._process_llm_response(
                    review, fallback, profile, batch_id, is_fallback=True
                )
                classified_reviews.append(classified)

        return classified_reviews, metadata

    async def _fallback_individual_processing(
        self,
        reviews: list[ReviewToClassify],
        profile: str,
        llm_client: LLMClientBase,
        batch_id: str,
        config: dict[str, Any],
    ) -> list[ClassifiedReview]:
        """
        Fallback to individual processing when batch fails.

        This ensures we can still classify reviews even if batching fails.
        """
        logger.warning(f"Falling back to individual processing for {len(reviews)} reviews")
        classified_reviews: list[ClassifiedReview] = []

        for review in reviews:
            try:
                classified, _ = await self._classify_review(
                    review, profile, llm_client, batch_id
                )
                if classified:
                    classified_reviews.append(classified)

                    if self.review_repo and self.span_repo:
                        await self._persist_classification(
                            classified, review, batch_id, config
                        )

            except Exception as e:
                logger.error(f"Individual classification failed for {review['review_id']}: {e}")
                # Use fallback
                fallback = create_fallback_response(review["text"])
                classified = self._process_llm_response(
                    review, fallback, profile, batch_id, is_fallback=True
                )
                classified_reviews.append(classified)

        return classified_reviews

    def _process_llm_response(
        self,
        review: ReviewToClassify,
        llm_response: LLMClassificationResponse,
        profile: str,
        batch_id: str,
        is_fallback: bool = False,
    ) -> ClassifiedReview:
        """
        Process an LLM response into a ClassifiedReview.

        This is shared logic for both batch and individual processing.
        """
        # Validate and fix response
        llm_response = self._validate_and_fix_response(llm_response, review["text"])

        # Convert spans to our format
        spans = self._convert_spans(
            llm_response["spans"],
            review,
            profile,
            batch_id,
        )

        # Ensure exactly one primary span
        spans = self._ensure_primary_span(spans)

        # Post-LLM validation
        spans = self._validate_span_classifications(spans)

        # Find primary span
        primary_span = next((s for s in spans if s.get("is_primary")), spans[0] if spans else None)

        # Generate embedding
        embedding: list[float] = []
        if self.embedding_service:
            embedding = self.embedding_service.embed(review["text_normalized"])

        # Calculate trust score
        trust_score = self._calculate_trust_score(review, spans)

        # Extract staff mentions and quotes
        staff_mentions = self._extract_staff_mentions(spans)
        quotes = self._extract_quotes(spans)

        return ClassifiedReview(
            source=review["source"],
            review_id=review["review_id"],
            review_version=review["review_version"],
            urt_primary=primary_span["urt_primary"] if primary_span else "O1.01",
            urt_secondary=primary_span.get("urt_secondary", []) if primary_span else [],
            valence=primary_span["valence"] if primary_span else "V0",
            intensity=primary_span["intensity"] if primary_span else "I1",
            comparative=primary_span.get("comparative", "CR-N") if primary_span else "CR-N",
            staff_mentions=staff_mentions,
            quotes=quotes,
            trust_score=trust_score,
            embedding=embedding,
            spans=spans,
            classification_confidence={
                "overall": 0.8 if not is_fallback else 0.3
            },
            processing_time_ms=0,  # Set at batch level
        )

    async def _classify_review(
        self,
        review: ReviewToClassify,
        profile: str,
        llm_client: LLMClientBase,
        batch_id: str,
    ) -> tuple[ClassifiedReview | None, dict[str, Any]]:
        """
        Classify a single review (used for fallback when batching fails).

        Args:
            review: Review to classify
            profile: Classification profile
            llm_client: LLM client instance
            batch_id: Batch identifier

        Returns:
            Tuple of (classified review, metadata)
        """
        metadata: dict[str, Any] = {}
        is_fallback = False

        # Call LLM for classification
        try:
            llm_response, llm_metadata = await llm_client.classify(
                review["text"],
                profile,
            )
            metadata.update(llm_metadata)
        except Exception as e:
            logger.warning(
                f"LLM classification failed for {review['review_id']}, "
                f"using fallback: {e}"
            )
            llm_response = create_fallback_response(review["text"])
            metadata["fallback"] = True
            is_fallback = True

        # Use shared processing logic
        classified = self._process_llm_response(
            review,
            llm_response,
            profile,
            batch_id,
            is_fallback=is_fallback,
        )

        return classified, metadata

    def _validate_and_fix_response(
        self,
        response: LLMClassificationResponse,
        original_text: str,
    ) -> LLMClassificationResponse:
        """
        Validate LLM response and fix common issues.

        Args:
            response: Raw LLM response
            original_text: Original review text for offset validation

        Returns:
            Validated and fixed response
        """
        spans = response.get("spans", [])
        if not spans:
            # Create fallback if no spans
            return create_fallback_response(original_text)

        fixed_spans = []
        for i, span in enumerate(spans):
            # Ensure required fields
            span["span_index"] = i

            # Validate and fix offsets
            start = span.get("span_start", 0)
            end = span.get("span_end", len(original_text))

            if start < 0:
                start = 0
            if end > len(original_text):
                end = len(original_text)
            if end <= start:
                end = start + len(span.get("span_text", "")) or len(original_text)

            span["span_start"] = start
            span["span_end"] = end

            # Validate URT code
            urt_primary = span.get("urt_primary", "O1.01")
            if not URT_CODE_PATTERN.match(urt_primary):
                logger.warning(f"Invalid URT code '{urt_primary}', defaulting to O1.01")
                span["urt_primary"] = "O1.01"

            # Ensure valid enums
            if span.get("valence") not in ("V+", "V-", "V0", "V±"):
                span["valence"] = "V0"
            if span.get("intensity") not in ("I1", "I2", "I3"):
                span["intensity"] = "I1"

            fixed_spans.append(span)

        response["spans"] = fixed_spans
        return response

    def _convert_spans(
        self,
        llm_spans: list[LLMSpanResponse],
        review: ReviewToClassify,
        profile: str,
        batch_id: str,
    ) -> list[ExtractedSpan]:
        """
        Convert LLM spans to our ExtractedSpan format.

        Args:
            llm_spans: Spans from LLM response
            review: Source review
            profile: Classification profile
            batch_id: Batch identifier

        Returns:
            List of ExtractedSpan objects
        """
        spans = []

        for llm_span in llm_spans:
            # Generate deterministic span ID
            span_key = f"{review['review_id']}:{llm_span['span_index']}:{llm_span.get('span_text', '')[:50]}"
            span_hash = hashlib.sha256(span_key.encode()).hexdigest()[:16]
            span_id = f"SPN-{span_hash}"

            span = ExtractedSpan(
                span_id=span_id,
                span_index=llm_span["span_index"],
                span_text=llm_span.get("span_text", ""),
                span_start=llm_span.get("span_start", 0),
                span_end=llm_span.get("span_end", 0),
                profile=profile,  # type: ignore
                urt_primary=llm_span["urt_primary"],
                urt_secondary=llm_span.get("urt_secondary", []),
                valence=llm_span["valence"],
                intensity=llm_span["intensity"],
                comparative=llm_span.get("comparative", "CR-N"),
                specificity=llm_span.get("specificity"),
                actionability=llm_span.get("actionability"),
                temporal=llm_span.get("temporal"),
                evidence=llm_span.get("evidence"),
                entity=llm_span.get("entity"),
                entity_type=llm_span.get("entity_type"),
                entity_normalized=llm_span.get("entity", "").lower() if llm_span.get("entity") else None,
                relation_type=llm_span.get("relation_type"),
                related_span_index=llm_span.get("related_span_index"),
                confidence=llm_span.get("confidence", "medium"),
                usn=llm_span.get("usn", self._generate_usn(llm_span)),
                is_primary=llm_span.get("is_primary", False),
            )
            spans.append(span)

        return spans

    def _ensure_primary_span(self, spans: list[ExtractedSpan]) -> list[ExtractedSpan]:
        """
        Ensure exactly one span is marked as primary.

        Uses selection rules:
        1. Highest intensity (I3 > I2 > I1)
        2. Tie-break: negative over positive (V- > V± > V0 > V+)
        3. Tie-break: earliest span_index

        Args:
            spans: List of spans

        Returns:
            List of spans with exactly one primary
        """
        if not spans:
            return spans

        # Count current primaries
        primary_count = sum(1 for s in spans if s.get("is_primary"))

        if primary_count == 1:
            return spans

        # Clear all primaries and re-select
        for span in spans:
            span["is_primary"] = False

        # Sort by selection criteria
        def sort_key(s: ExtractedSpan) -> tuple[int, int, int]:
            return (
                INTENSITY_PRIORITY.get(s["intensity"], 2),
                VALENCE_PRIORITY.get(s["valence"], 3),
                s["span_index"],
            )

        sorted_spans = sorted(spans, key=sort_key)
        sorted_spans[0]["is_primary"] = True

        return spans

    def _validate_span_classifications(
        self,
        spans: list[ExtractedSpan],
    ) -> list[ExtractedSpan]:
        """
        Post-LLM validation to catch common misclassifications.

        Uses keyword detection to identify obvious errors like:
        - Price mentions classified as P codes (should be V)
        - Staff behavior classified as A codes (should be P)
        - Scam mentions classified as P/V codes (should be R)

        Args:
            spans: List of classified spans

        Returns:
            List of spans with corrections applied
        """
        corrections = 0
        for span in spans:
            correction = validate_classification(
                span.get("span_text", ""),
                span.get("urt_primary", "O1.01"),
                span.get("valence", "V0"),
            )
            if correction:
                original = span["urt_primary"]
                span["urt_primary"] = correction["suggested_urt"]
                corrections += 1
                logger.debug(
                    f"Validation corrected {original} → {correction['suggested_urt']} "
                    f"({correction['reason']})"
                )

        if corrections:
            logger.info(f"Post-LLM validation corrected {corrections} spans")

        return spans

    def _calculate_trust_score(
        self,
        review: ReviewToClassify,
        spans: list[ExtractedSpan],
    ) -> float:
        """
        Calculate trust score for a review.

        Factors:
        - Text length (longer = more trust)
        - Specificity of spans
        - Confidence levels

        Args:
            review: Source review
            spans: Classified spans

        Returns:
            Trust score between 0.2 and 1.0
        """
        score = 0.5  # Base score

        # Length factor (up to +0.2)
        text_len = len(review["text"])
        if text_len > 200:
            score += 0.2
        elif text_len > 100:
            score += 0.1
        elif text_len > 50:
            score += 0.05

        # Specificity factor (up to +0.2)
        if spans:
            high_spec_count = sum(1 for s in spans if s.get("specificity") == "S3")
            if high_spec_count > 0:
                score += 0.1 + (0.1 * min(high_spec_count / len(spans), 1.0))

        # Confidence factor (up to +0.1)
        if spans:
            high_conf_count = sum(1 for s in spans if s.get("confidence") == "high")
            score += 0.1 * (high_conf_count / len(spans))

        # Ensure floor of 0.2 and ceiling of 1.0
        return max(self.config.trust_score_floor, min(1.0, score))

    def _extract_staff_mentions(self, spans: list[ExtractedSpan]) -> list[str]:
        """Extract staff names from spans."""
        staff = []
        for span in spans:
            if span.get("entity_type") == "staff" and span.get("entity"):
                staff.append(span["entity"])
        return list(set(staff))

    def _extract_quotes(self, spans: list[ExtractedSpan]) -> dict[str, str]:
        """Extract representative quotes by URT code."""
        quotes = {}
        for span in spans:
            code = span["urt_primary"]
            if code not in quotes:
                quotes[code] = span["span_text"][:100]
        return quotes

    def _create_skip_classification(
        self,
        review: ReviewToClassify,
        skip_classification: dict,
        batch_id: str,
    ) -> ClassifiedReview:
        """
        Create a ClassifiedReview for a SKIP tier review (no LLM).

        Args:
            review: Source review
            skip_classification: Pre-assigned classification from router
            batch_id: Batch identifier

        Returns:
            ClassifiedReview with generic classification
        """
        urt_primary = skip_classification.get("urt_primary", "V4.03")
        valence = skip_classification.get("valence", "V0")
        intensity = skip_classification.get("intensity", "I1")

        # Create a single span for the entire review
        span_key = f"{review['review_id']}:0:{review['text'][:50]}"
        span_hash = hashlib.sha256(span_key.encode()).hexdigest()[:16]
        span_id = f"SPN-{span_hash}"

        span = ExtractedSpan(
            span_id=span_id,
            span_index=0,
            span_text=review["text"],
            span_start=0,
            span_end=len(review["text"]),
            profile="lite",  # type: ignore
            urt_primary=urt_primary,
            urt_secondary=[],
            valence=valence,
            intensity=intensity,
            comparative="CR-N",
            confidence="low",
            usn=f"URT:S:{urt_primary}:{valence[1]}{intensity[1]}:11TC.ES.N",
            is_primary=True,
        )

        # Generate embedding if available
        embedding: list[float] = []
        if self.embedding_service:
            embedding = self.embedding_service.embed(review.get("text_normalized", review["text"]))

        return ClassifiedReview(
            source=review["source"],
            review_id=review["review_id"],
            review_version=review["review_version"],
            urt_primary=urt_primary,
            urt_secondary=[],
            valence=valence,
            intensity=intensity,
            comparative="CR-N",
            staff_mentions=[],
            quotes={},
            trust_score=self.config.trust_score_floor,  # Minimum trust for skipped reviews
            embedding=embedding,
            spans=[span],
            classification_confidence={"overall": 0.2, "skip_reason": skip_classification.get("skip_reason", "auto_routed")},
            processing_time_ms=0,
        )

    def _generate_usn(self, span: LLMSpanResponse) -> str:
        """
        Generate USN (URT String Notation) for a span.

        Format: URT:S:{primary}[+{sec}]:{valence_sign}{intensity_num}:{S#}{A#}{temporal}.{evidence}.{CR_suffix}
        """
        primary = span.get("urt_primary", "O1.01")
        secondary = span.get("urt_secondary", [])
        valence = span.get("valence", "V0")
        intensity = span.get("intensity", "I1")
        specificity = span.get("specificity", "S1")
        actionability = span.get("actionability", "A1")
        temporal = span.get("temporal", "TC")
        evidence = span.get("evidence", "ES")
        comparative = span.get("comparative", "CR-N")

        # Build code portion
        code_part = primary
        for sec in secondary[:2]:
            code_part += f"+{sec}"

        # Valence encoding
        valence_map = {"V+": "+", "V-": "-", "V0": "0", "V±": "±"}
        valence_sign = valence_map.get(valence, "0")

        # Intensity number
        intensity_num = intensity[1] if intensity.startswith("I") else "1"

        # Dimensions
        spec_num = specificity[1] if specificity and specificity.startswith("S") else "1"
        act_num = actionability[1] if actionability and actionability.startswith("A") else "1"

        # CR suffix
        cr_map = {"CR-N": "N", "CR-B": "B", "CR-W": "W", "CR-S": "S"}
        cr_suffix = cr_map.get(comparative, "N")

        return f"URT:S:{code_part}:{valence_sign}{intensity_num}:{spec_num}{act_num}{temporal}.{evidence}.{cr_suffix}"

    async def _persist_classification(
        self,
        classified: ClassifiedReview,
        review: ReviewToClassify,
        batch_id: str,
        config: dict[str, Any],
    ) -> None:
        """Persist classification results to database."""
        if not self.review_repo or not self.span_repo:
            return

        # Update reviews_enriched
        await self.review_repo.update_enriched_with_classification(
            classified,
            self.config.llm_model,
            config["taxonomy_version"],
        )

        # Insert spans
        for span in classified.get("spans", []):
            await self.span_repo.insert_span(
                span,
                review["business_id"],
                review["place_id"],
                review["source"],
                review["review_id"],
                review["review_version"],
                review["review_time"],
                batch_id,
                self.config.llm_model,
                config["taxonomy_version"],
                job_id=config.get("job_id"),
            )
