"""Anthropic implementation of structured intent and streamed reranking."""

import json
import re
from collections.abc import AsyncGenerator
from typing import Literal, cast

from anthropic import AsyncAnthropic
from anthropic.lib._parse._transform import transform_schema
from anthropic.types import Message, TextBlockParam, Usage
from pydantic import BaseModel, ConfigDict, Field, ValidationError

from app.config import Settings
from app.domain.models import (
    ConversationTurn,
    Familiarity,
    Intent,
    PreviousRecommendation,
    RerankSelection,
    Track,
    TrackCandidate,
)
from app.observability.timing import record_llm_tokens
from app.ports.protocols import RecommenderOutputError
from app.prompts import INTENT_SYSTEM_PROMPT, RERANK_SYSTEM_PROMPT

Effort = Literal["low", "medium", "high", "xhigh", "max"]
_RECOMMENDATION_ARRAY = re.compile(r'"recommendations"\s*:\s*\[')


class CandidateOutput(BaseModel):
    """One bounded candidate in the intent response."""

    model_config = ConfigDict(extra="forbid")

    title: str = Field(min_length=1, max_length=200)
    artist: str = Field(min_length=1, max_length=200)


class IntentOutput(BaseModel):
    """Bounded structured output for the intent call."""

    model_config = ConfigDict(extra="forbid")

    mood: list[str] = Field(default_factory=list, max_length=6)
    activity: str | None = Field(default=None, max_length=100)
    era: list[str] = Field(default_factory=list, max_length=5)
    languages: list[str] = Field(default_factory=list, max_length=8)
    genres: list[str] = Field(default_factory=list, max_length=8)
    familiarity: Familiarity
    is_refinement: bool
    intent_summary: str = Field(min_length=1, max_length=300, pattern=r"^[^\r\n]+$")
    candidates: list[CandidateOutput] = Field(min_length=10, max_length=50)


class RerankSelectionOutput(BaseModel):
    """One validated object extracted from the rerank stream."""

    model_config = ConfigDict(extra="forbid")

    track_id: str = Field(min_length=1, max_length=200)
    justification: str = Field(min_length=1, max_length=300, pattern=r"^[^\r\n]+$")


class RerankOutput(BaseModel):
    """Complete bounded rerank output used for final validation."""

    model_config = ConfigDict(extra="forbid")

    recommendations: list[RerankSelectionOutput] = Field(min_length=1, max_length=50)


class AnthropicRecommender:
    """Use two Anthropic calls for intent generation and grounded ranking."""

    def __init__(self, client: AsyncAnthropic, settings: Settings) -> None:
        """Bind the shared asynchronous client and immutable settings."""
        self.client = client
        self.settings = settings

    async def create_intent(
        self,
        query: str,
        history: tuple[ConversationTurn, ...],
        previous_recommendations: tuple[PreviousRecommendation, ...],
        taste_summary: str,
        candidate_count: int,
    ) -> Intent:
        """Interpret a request through Anthropic structured output."""
        message = await self.client.messages.parse(
            model=self.settings.llm_model,
            max_tokens=self.settings.intent_max_tokens,
            output_config={"effort": _parse_effort(self.settings.intent_effort)},
            output_format=IntentOutput,
            system=[_system_block(INTENT_SYSTEM_PROMPT)],
            messages=[
                {
                    "role": "user",
                    "content": _render_intent_input(
                        query,
                        history,
                        previous_recommendations,
                        taste_summary,
                        candidate_count,
                    ),
                }
            ],
        )
        _validate_stop_reason(message)
        _record_usage(message.usage)
        parsed = message.parsed_output
        if parsed is None:
            raise RecommenderOutputError("Intent response contained no structured output")
        validated = IntentOutput.model_validate(parsed.model_dump())
        return _to_intent(validated)

    async def stream_rerank(
        self,
        intent: Intent,
        grounded_tracks: tuple[Track, ...],
        taste_summary: str,
        history: tuple[ConversationTurn, ...],
        selection_count: int,
        correction: str | None = None,
    ) -> AsyncGenerator[RerankSelection]:
        """Yield each complete valid selection while the JSON is streaming."""
        schema = transform_schema(RerankOutput.model_json_schema())
        parser = _RecommendationObjectParser()
        async with self.client.messages.stream(
            model=self.settings.llm_model,
            max_tokens=self.settings.rerank_max_tokens,
            output_config={
                "effort": _parse_effort(self.settings.rerank_effort),
                "format": {"type": "json_schema", "schema": schema},
            },
            system=[_system_block(RERANK_SYSTEM_PROMPT)],
            messages=[
                {
                    "role": "user",
                    "content": _render_rerank_input(
                        intent,
                        grounded_tracks,
                        taste_summary,
                        history,
                        selection_count,
                        correction,
                    ),
                }
            ],
        ) as stream:
            async for text_delta in stream.text_stream:
                for selection in parser.feed(text_delta):
                    yield RerankSelection(
                        track_id=selection.track_id,
                        justification=selection.justification,
                    )
            final_message = await stream.get_final_message()

        _validate_stop_reason(final_message)
        _record_usage(final_message.usage)
        try:
            validated = RerankOutput.model_validate_json(parser.complete_text)
        except ValidationError as error:
            raise RecommenderOutputError("Rerank response failed final validation") from error
        if len(validated.recommendations) > selection_count:
            raise RecommenderOutputError("Rerank response returned too many selections")


class _RecommendationObjectParser:
    def __init__(self) -> None:
        self.complete_text = ""
        self._scan_index = 0
        self._object_start: int | None = None
        self._object_depth = 0
        self._is_in_string = False
        self._is_escaped = False
        self._has_found_array = False

    def feed(self, text_delta: str) -> list[RerankSelectionOutput]:
        self.complete_text += text_delta
        if not self._has_found_array:
            match = _RECOMMENDATION_ARRAY.search(self.complete_text)
            if match is None:
                return []
            self._has_found_array = True
            self._scan_index = match.end()

        selections: list[RerankSelectionOutput] = []
        while self._scan_index < len(self.complete_text):
            character = self.complete_text[self._scan_index]
            completed = self._scan_character(character)
            self._scan_index += 1
            if completed is not None:
                selections.append(completed)
        return selections

    def _scan_character(self, character: str) -> RerankSelectionOutput | None:
        if self._object_start is None:
            if character == "{":
                self._object_start = self._scan_index
                self._object_depth = 1
            return None

        if self._is_in_string:
            if self._is_escaped:
                self._is_escaped = False
            elif character == "\\":
                self._is_escaped = True
            elif character == '"':
                self._is_in_string = False
            return None

        if character == '"':
            self._is_in_string = True
        elif character == "{":
            self._object_depth += 1
        elif character == "}":
            self._object_depth -= 1
            if self._object_depth == 0:
                return self._finish_object()
        return None

    def _finish_object(self) -> RerankSelectionOutput:
        if self._object_start is None:
            raise RecommenderOutputError("Rerank parser lost the object start position")
        object_text = self.complete_text[self._object_start : self._scan_index + 1]
        self._object_start = None
        try:
            return RerankSelectionOutput.model_validate_json(object_text)
        except ValidationError as error:
            raise RecommenderOutputError("Rerank item failed validation") from error


def _to_intent(output: IntentOutput) -> Intent:
    return Intent(
        mood=tuple(output.mood),
        activity=output.activity,
        era=tuple(output.era),
        languages=tuple(output.languages),
        genres=tuple(output.genres),
        familiarity=output.familiarity,
        is_refinement=output.is_refinement,
        intent_summary=output.intent_summary,
        candidates=tuple(
            TrackCandidate(title=candidate.title, artist=candidate.artist)
            for candidate in output.candidates
        ),
    )


def _render_intent_input(
    query: str,
    history: tuple[ConversationTurn, ...],
    previous_recommendations: tuple[PreviousRecommendation, ...],
    taste_summary: str,
    candidate_count: int,
) -> str:
    payload = {
        "query": query,
        "history": [turn.__dict__ for turn in history],
        "prior_recommendations": [
            recommendation.__dict__ for recommendation in previous_recommendations
        ],
        "taste_profile": taste_summary,
        "required_candidate_count": candidate_count,
    }
    return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))


def _render_rerank_input(
    intent: Intent,
    grounded_tracks: tuple[Track, ...],
    taste_summary: str,
    history: tuple[ConversationTurn, ...],
    selection_count: int,
    correction: str | None,
) -> str:
    payload = {
        "intent": {
            "mood": intent.mood,
            "activity": intent.activity,
            "era": intent.era,
            "languages": intent.languages,
            "genres": intent.genres,
            "familiarity": intent.familiarity,
            "intent_summary": intent.intent_summary,
        },
        "grounded_pool": [
            {"track_id": track.id, "title": track.title, "artists": track.artists}
            for track in grounded_tracks
        ],
        "taste_profile": taste_summary,
        "history": [turn.__dict__ for turn in history],
        "requested_selection_count": selection_count,
        "correction": correction,
    }
    return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))


def _system_block(prompt: str) -> TextBlockParam:
    return {
        "type": "text",
        "text": prompt,
        "cache_control": {"type": "ephemeral"},
    }


def _parse_effort(value: str) -> Effort:
    allowed = {"low", "medium", "high", "xhigh", "max"}
    if value not in allowed:
        raise ValueError(f"Unsupported Anthropic effort: {value}")
    return cast(Effort, value)


def _validate_stop_reason(message: Message) -> None:
    stop_reason_messages = {
        None: "Anthropic response had no stop reason",
        "max_tokens": "Anthropic response reached its token limit",
        "stop_sequence": "Anthropic response hit an unexpected stop sequence",
        "tool_use": "Anthropic response attempted tool use",
        "pause_turn": "Anthropic response paused before completion",
        "refusal": "Anthropic response was refused",
        "model_context_window_exceeded": "Anthropic context window was exceeded",
    }
    if message.stop_reason == "end_turn":
        return
    raise RecommenderOutputError(
        stop_reason_messages.get(message.stop_reason, "Anthropic response stopped unexpectedly")
    )


def _record_usage(usage: Usage) -> None:
    cache_creation_tokens = usage.cache_creation_input_tokens or 0
    cache_read_tokens = usage.cache_read_input_tokens or 0
    record_llm_tokens(
        usage.input_tokens + cache_creation_tokens + cache_read_tokens,
        usage.output_tokens,
    )
