from __future__ import annotations

import asyncio
from time import perf_counter
from typing import TYPE_CHECKING

from app.agent.runtime import RuntimeContext
from app.agent.working_memory import build_reactor_working_memory
from app.cognition.errors import UpstreamApiUnavailableError
from app.infra.logging import get_logger
from app.infra.settings import get_settings
from app.scenario.base import Scenario
from app.scenario.bundle_registry import resolve_default_scenario_id
from app.scenario.runtime.world_design import load_world_design_runtime_package
from app.scenario.runtime_config import build_scenario_runtime_config
from app.scenario.types import get_agent_config_id, get_scenario_guidance, get_world_role
from app.sim.action_resolver import ActionIntent, ActionResolver
from app.sim.agent_snapshot_builder import build_agent_recent_events
from app.sim.llm_call_collector import LlmCallCollector
from app.sim.runner import SimulationRunner, TickResult
from app.sim.runtime_context_utils import (
    build_agent_world_context,
    extract_subject_alert_from_agent_data,
    extract_subject_alert_from_agents,
    inject_profile_fields_into_context,
)
from app.sim.world import WorldState
from app.sim.world_queries import find_recent_conversation_partner, get_agent

if TYPE_CHECKING:
    from sqlalchemy.ext.asyncio import AsyncSession

    from app.agent.runtime import AgentRuntime
    from app.sim.context import ContextBuilder
    from app.sim.types import AgentDecisionSnapshot
    from app.store.models import Agent, LlmCall
    from app.store.repositories import AgentRepository


logger = get_logger(__name__)


class TickOrchestrator:
    def __init__(
        self,
        *,
        agent_runtime: AgentRuntime,
        scenario: Scenario,
        session: AsyncSession | None = None,
        context_builder: ContextBuilder | None = None,
        agent_repo: AgentRepository | None = None,
    ) -> None:
        self.agent_runtime = agent_runtime
        self.scenario = scenario
        self.session = session
        self.context_builder = context_builder
        self.agent_repo = agent_repo
        scenario_id = getattr(scenario, "scenario_id", None) or resolve_default_scenario_id()
        self._runtime_role_semantics = build_scenario_runtime_config(scenario_id)
        self._subject_alert_tracking_enabled = self._runtime_role_semantics.subject_alert_tracking

    async def prepare_tick_intents(self, run_id: str, world: WorldState) -> list[ActionIntent]:
        if self.session is None or self.context_builder is None or self.agent_repo is None:
            msg = "TickOrchestrator.prepare_tick_intents requires a bound session context"
            raise RuntimeError(msg)

        started_at = perf_counter()
        agents = await self.agent_repo.list_for_run(run_id)
        intents: list[ActionIntent] = []
        subject_alert_score = (
            extract_subject_alert_from_agents(
                agents,
                world,
                semantics=self._runtime_role_semantics,
            )
            if self._subject_alert_tracking_enabled
            else None
        )
        plan = await self.scenario.build_director_plan(run_id, agents)
        agent_recent_events = await build_agent_recent_events(
            session=self.session,
            run_id=run_id,
            agents=list(agents),
            agent_states=world.agents,
            location_states=world.locations,
        )

        for agent in agents:
            state = get_agent(world, agent.id)
            if state is None:
                continue

            runtime_agent_id = self.resolve_runtime_agent_id(agent)
            profile = self.scenario.merge_agent_profile(agent, plan)
            intents.append(
                await self.decide_intent_for_agent(
                    agent_id=agent.id,
                    runtime_agent_id=runtime_agent_id,
                    world=world,
                    current_goal=agent.current_goal,
                    current_location_id=state.location_id,
                    home_location_id=agent.home_location_id,
                    current_status=state.status,
                    profile=profile,
                    recent_events=agent_recent_events.get(agent.id, []),
                    subject_alert_score=subject_alert_score,
                    relationship_context=world.relationship_contexts.get(agent.id),
                )
            )

        logger.debug(
            "tick_phase_completed run_id=%s tick_no=%s phase=prepare_tick_intents "
            "duration_ms=%s agent_count=%s intent_count=%s",
            run_id,
            world.current_tick,
            int((perf_counter() - started_at) * 1000),
            len(agents),
            len(intents),
        )
        return intents

    async def prepare_intents_from_data(
        self,
        world: WorldState,
        agent_data: list[AgentDecisionSnapshot],
        engine=None,
        run_id: str | None = None,
        tick_no: int = 0,
    ) -> tuple[list[ActionIntent], list[LlmCall]]:
        phase_started_at = perf_counter()
        collector = LlmCallCollector()
        settings = get_settings()
        concurrency_limit = self.agent_runtime.decision_concurrency_limit()
        semaphore = (
            asyncio.Semaphore(concurrency_limit)
            if isinstance(concurrency_limit, int) and concurrency_limit > 0
            else None
        )

        async def decide_for_agent(agent_snapshot: AgentDecisionSnapshot) -> ActionIntent | None:
            enqueued_at = perf_counter()
            if semaphore is None:
                return await _decide_for_agent_inner(agent_snapshot, enqueued_at=enqueued_at)
            async with semaphore:
                return await _decide_for_agent_inner(agent_snapshot, enqueued_at=enqueued_at)

        async def _decide_for_agent_inner(
            agent_snapshot: AgentDecisionSnapshot,
            *,
            enqueued_at: float,
        ) -> ActionIntent | None:
            agent_id = agent_snapshot.id
            state = get_agent(world, agent_id)
            if state is None:
                return None

            profile = agent_snapshot.profile
            runtime_agent_id = get_agent_config_id(profile) or agent_id
            subject_alert_score = (
                extract_subject_alert_from_agent_data(
                    agent_data,
                    world,
                    semantics=self._runtime_role_semantics,
                )
                if self._subject_alert_tracking_enabled
                else None
            )

            runtime_ctx = None
            if run_id is not None:
                db_agent_id = agent_snapshot.id

                from app.agent.memory_cache import MemoryCache

                memory_cache = (
                    MemoryCache(agent_snapshot.memory_cache)
                    if agent_snapshot.memory_cache
                    else None
                )

                runtime_ctx = RuntimeContext(
                    db_engine=engine,
                    run_id=run_id,
                    enable_memory_tools=True,
                    on_llm_call=collector.build_callback(
                        run_id=run_id,
                        db_agent_id=db_agent_id,
                        tick_no=tick_no,
                        provider=settings.llm_provider,
                        model=settings.llm_model,
                    ),
                    memory_cache=memory_cache,
                )

            workplace_location_id = None
            if isinstance(profile, dict):
                workplace_location_id = profile.get("workplace_location_id")

            started_at = perf_counter()
            queue_delay_ms = int((started_at - enqueued_at) * 1000)
            logger.debug(
                "agent_decision_started run_id=%s tick_no=%s agent_id=%s runtime_agent_id=%s "
                "queue_delay_ms=%s current_location_id=%s",
                run_id,
                tick_no,
                agent_id,
                runtime_agent_id,
                queue_delay_ms,
                agent_snapshot.current_location_id,
            )

            try:
                intent = await self.decide_intent_for_agent(
                    agent_id=agent_id,
                    runtime_agent_id=runtime_agent_id,
                    world=world,
                    current_goal=agent_snapshot.current_goal,
                    current_location_id=agent_snapshot.current_location_id,
                    home_location_id=agent_snapshot.home_location_id,
                    current_status=state.status,
                    profile=profile if isinstance(profile, dict) else {},
                    recent_events=agent_snapshot.recent_events,
                    subject_alert_score=subject_alert_score,
                    runtime_ctx=runtime_ctx,
                    workplace_location_id=workplace_location_id,
                    current_plan=agent_snapshot.current_plan,
                    relationship_context=agent_snapshot.relationship_context,
                )
            except UpstreamApiUnavailableError:
                raise
            except Exception as exc:
                logger.exception(
                    "agent_decision_failed run_id=%s tick_no=%s agent_id=%s runtime_agent_id=%s "
                    "queue_delay_ms=%s",
                    run_id,
                    tick_no,
                    agent_id,
                    runtime_agent_id,
                    queue_delay_ms,
                )
                fallback_intent = self._build_fallback_intent(
                    agent_id=agent_id,
                    current_location_id=agent_snapshot.current_location_id,
                    home_location_id=agent_snapshot.home_location_id,
                    world=world,
                    profile=profile if isinstance(profile, dict) else {},
                    current_status=state.status,
                    recent_events=agent_snapshot.recent_events,
                )
                logger.warning(
                    "agent_decision_recovered_with_fallback run_id=%s tick_no=%s agent_id=%s "
                    "runtime_agent_id=%s fallback_action_type=%s error=%s",
                    run_id,
                    tick_no,
                    agent_id,
                    runtime_agent_id,
                    fallback_intent.action_type,
                    exc,
                )
                return fallback_intent

            logger.debug(
                "agent_decision_completed run_id=%s tick_no=%s agent_id=%s runtime_agent_id=%s "
                "queue_delay_ms=%s duration_ms=%s action_type=%s target_agent_id=%s "
                "target_location_id=%s",
                run_id,
                tick_no,
                agent_id,
                runtime_agent_id,
                queue_delay_ms,
                int((perf_counter() - started_at) * 1000),
                intent.action_type,
                intent.target_agent_id,
                intent.target_location_id,
            )
            return intent

        results = await asyncio.gather(*[decide_for_agent(snapshot) for snapshot in agent_data])
        intents = [result for result in results if result is not None]
        logger.debug(
            "tick_phase_completed run_id=%s tick_no=%s phase=decide_intents duration_ms=%s "
            "agent_count=%s intent_count=%s llm_call_count=%s concurrency_limit=%s",
            run_id,
            tick_no,
            int((perf_counter() - phase_started_at) * 1000),
            len(agent_data),
            len(intents),
            len(collector.records),
            concurrency_limit,
        )
        return intents, collector.records

    def _build_fallback_intent(
        self,
        *,
        agent_id: str,
        current_location_id: str | None,
        home_location_id: str | None,
        world: WorldState,
        profile: dict,
        current_status: dict | None,
        recent_events: list[dict],
    ) -> ActionIntent:
        nearby_agent_id = (
            find_recent_conversation_partner(
                world,
                agent_id,
                current_location_id,
                recent_events=recent_events,
            )
            if current_location_id is not None
            else None
        )
        fallback_intent = self.scenario.fallback_intent(
            agent_id=agent_id,
            current_location_id=current_location_id or home_location_id or "",
            home_location_id=home_location_id,
            nearby_agent_id=nearby_agent_id,
            world_role=get_world_role(profile),
            current_status=current_status,
            scenario_guidance=get_scenario_guidance(profile),
        )
        if fallback_intent is not None:
            return fallback_intent

        return ActionIntent(
            agent_id=agent_id,
            action_type="rest",
        )

    async def decide_intent_for_agent(
        self,
        *,
        agent_id: str,
        runtime_agent_id: str,
        world: WorldState,
        current_goal: str | None,
        current_location_id: str | None,
        home_location_id: str | None,
        current_status: dict | None,
        profile: dict,
        recent_events: list[dict],
        subject_alert_score: float | None = 0.0,
        runtime_ctx=None,
        workplace_location_id: str | None = None,
        current_plan: dict | None = None,
        relationship_context: dict[str, dict[str, object]] | None = None,
    ) -> ActionIntent:
        nearby_agent_id = (
            find_recent_conversation_partner(
                world,
                agent_id,
                current_location_id,
                recent_events=recent_events,
            )
            if current_location_id is not None
            else None
        )
        director_guidance = get_scenario_guidance(profile)

        world_ctx = build_agent_world_context(
            agent_id=agent_id,
            world=world,
            current_goal=current_goal,
            current_location_id=current_location_id,
            home_location_id=home_location_id,
            nearby_agent_id=nearby_agent_id,
            current_status=current_status,
            subject_alert_score=subject_alert_score,
            world_role=get_world_role(profile),
            director_guidance=director_guidance,
            workplace_location_id=workplace_location_id,
            current_plan=current_plan,
            relationship_context=relationship_context,
            recent_events=recent_events,
        )
        inject_profile_fields_into_context(world_ctx, profile)
        if runtime_ctx is not None and runtime_ctx.memory_cache is not None:
            runtime_ctx.memory_cache = runtime_ctx.memory_cache.with_working_memory(
                build_reactor_working_memory(world_ctx, {"recent": []})
            )
        intent = await self.agent_runtime.react(
            runtime_agent_id,
            world=world_ctx,
            memory={"recent": []},
            event={},
            recent_events=recent_events,
            runtime_ctx=runtime_ctx,
        )
        intent.agent_id = agent_id
        intent = self._apply_pending_reply_bias(intent=intent, world_ctx=world_ctx)
        return self._apply_conversation_state_guard(intent=intent, world_ctx=world_ctx)

    def execute_tick(
        self,
        *,
        run_id: str | None,
        world: WorldState,
        current_tick: int,
        intents: list[ActionIntent],
    ) -> TickResult:
        started_at = perf_counter()
        scenario_id = getattr(self.scenario, "scenario_id", None) or resolve_default_scenario_id()
        runner = SimulationRunner(
            world,
            resolver=ActionResolver(
                world_design_package=load_world_design_runtime_package(scenario_id)
            ),
        )
        runner.tick_no = current_tick
        result = runner.tick(intents)
        logger.debug(
            "tick_phase_completed run_id=%s tick_no=%s phase=execute_tick duration_ms=%s "
            "accepted_count=%s rejected_count=%s",
            run_id,
            current_tick,
            int((perf_counter() - started_at) * 1000),
            len(result.accepted),
            len(result.rejected),
        )
        return result

    @staticmethod
    def _apply_pending_reply_bias(intent: ActionIntent, world_ctx: dict) -> ActionIntent:
        if intent.action_type != "rest":
            return intent

        pending_reply = world_ctx.get("pending_reply")
        if not isinstance(pending_reply, dict):
            return intent

        target_agent_id = pending_reply.get("from_agent_id")
        if not isinstance(target_agent_id, str) or not target_agent_id:
            return intent

        target_name = pending_reply.get("from_agent_name")
        message = TickOrchestrator._build_pending_reply_message(
            target_name=target_name if isinstance(target_name, str) else None,
            previous_message=(
                pending_reply.get("message")
                if isinstance(pending_reply.get("message"), str)
                else ""
            ),
            is_question=bool(pending_reply.get("is_question")),
        )

        return ActionIntent(
            agent_id=intent.agent_id,
            action_type="talk",
            target_agent_id=target_agent_id,
            payload={"message": message, "intent_source": "pending_reply_bias"},
        )

    @staticmethod
    def _build_pending_reply_message(
        *,
        target_name: str | None,
        previous_message: str,
        is_question: bool,
    ) -> str:
        name = target_name or "你"
        if is_question:
            return (
                f"{name}，我刚听到你刚才说的了。这个我愿意接着聊两句，"
                "你刚才提到的那个点我也有点想法，我们顺着说下去吧。"
            )
        if "一起" in previous_message or "要不要" in previous_message:
            return (
                f"{name}，可以啊。我刚才听到你的提议了，"
                "如果你现在方便，我们就顺着刚才的话题继续聊聊。"
            )
        return f"{name}，我刚才听到你说的了。既然我们还在这儿，就顺着刚才的话题再聊两句吧。"

    @staticmethod
    def _apply_conversation_state_guard(intent: ActionIntent, world_ctx: dict) -> ActionIntent:
        if intent.action_type != "talk":
            return intent

        conversation_state = world_ctx.get("conversation_state")
        if not isinstance(conversation_state, dict):
            return intent

        repeat_count = conversation_state.get("repeat_count")
        if not isinstance(repeat_count, int) or repeat_count < 1:
            return intent

        if intent.target_agent_id != world_ctx.get("nearby_agent_id"):
            return intent

        message = intent.payload.get("message")
        if not isinstance(message, str) or not message.strip():
            return intent

        last_proposal = conversation_state.get("last_proposal")
        last_message_summary = conversation_state.get("last_message_summary")
        normalized_message = TickOrchestrator._normalize_conversation_text(message)
        repeated_targets = [
            value
            for value in (last_proposal, last_message_summary)
            if isinstance(value, str) and value.strip()
        ]
        if not repeated_targets:
            return intent

        if any(
            TickOrchestrator._normalize_conversation_text(value) == normalized_message
            for value in repeated_targets
        ) or any(
            TickOrchestrator._conversation_overlap_score(value, message) >= 0.45
            for value in repeated_targets
        ):
            return ActionIntent(
                agent_id=intent.agent_id,
                action_type="rest",
                payload={
                    "intent_source": "conversation_repeat_guard",
                    "guard_reason": "repeated_proposal_threshold",
                },
            )

        return intent

    @staticmethod
    def _normalize_conversation_text(text: str) -> str:
        return "".join(text.split()).strip("，。！？,.!?：:;；\"'“”‘’").lower()

    @staticmethod
    def _conversation_overlap_score(left: str, right: str) -> float:
        normalized_left = TickOrchestrator._normalize_conversation_text(left)
        normalized_right = TickOrchestrator._normalize_conversation_text(right)
        if not normalized_left or not normalized_right:
            return 0.0

        def _char_windows(text: str, width: int = 8) -> set[str]:
            if len(text) <= width:
                return {text}
            return {text[idx : idx + width] for idx in range(len(text) - width + 1)}

        left_windows = _char_windows(normalized_left)
        right_windows = _char_windows(normalized_right)
        if not left_windows or not right_windows:
            return 0.0
        overlap = left_windows & right_windows
        return len(overlap) / min(len(left_windows), len(right_windows))

    @staticmethod
    def resolve_runtime_agent_id(agent: Agent) -> str:
        return get_agent_config_id(agent.profile) or agent.id
