"""Proactive chat pipeline — assistant-only turns, no forged user messages."""

from __future__ import annotations

from dataclasses import dataclass, replace
from datetime import datetime, timezone
from typing import Any, Iterator
from uuid import uuid4

from niannian.chat.proactive_associate import build_association_brief
from niannian.chat.proactive_correction import CorrectionResult, p15_reflect_candidates, p16_correct
from niannian.skills.context import SkillInjectContext
from niannian.skills.injector import inject_skills
from niannian.chat.proactive_persona_policy import load_vp_proactive_policy
from niannian.chat.proactive_profile import load_proactive_profile, save_proactive_profile
from niannian.chat.proactive_reflect import build_reflect_brief, load_proactive_assembly_excerpt
from niannian.chat.proactive_scope import resolve_proactive_scope
from niannian.chat.proactive_scheduler import initial_burst_count
from niannian.chat.session import Session, SessionNotFoundError, Turn, TurnInProgressError, _now_iso
from niannian.chat.sticker_proactive import pick_assistant_sticker
from niannian.chat.stickers import STICKER_PACK_ID
from niannian.token.budget import (
    chat_llm_allows,
    chat_sticker_allows,
    load_token_budget,
    resolve_chat_sticker_level,
    resolve_proactive_chat_burst,
    resolve_proactive_disturb_lock,
)
from niannian.virtual_persona.pack import read_equipped_skills
from niannian.virtual_persona.store import VirtualPersonaStore

MIN_PROACTIVE_INTERVAL_SEC = 15.0


class ProactiveForbidden(Exception):
    def __init__(self, code: str) -> None:
        self.code = code
        super().__init__(code)


class ProactiveRateLimited(Exception):
    def __init__(self, code: str) -> None:
        self.code = code
        super().__init__(code)


@dataclass(frozen=True)
class ProactiveRequest:
    scenario: str | None = None
    local_hour: int | None = None
    client_delay_ms: int | None = None
    stream: bool = True


@dataclass
class ProactivePrepareResult:
    hold_back: bool
    reason: str = ""


def _parse_iso(value: str | None) -> datetime | None:
    if not value:
        return None
    try:
        dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt
    except ValueError:
        return None


def _effective_chat_mode(session: Session) -> str:
    mode = session.meta.get("chat_mode") or "auto"
    lock = session.meta.get("regime_lock")
    if lock == "work" or mode == "work":
        return "work"
    return str(mode)


def _session_scenario(session: Session) -> str:
    if not any(t.role == "user" for t in session.turns):
        return "empty"
    return "post_chat"


def _last_user_at(session: Session) -> datetime | None:
    for turn in reversed(session.turns):
        if turn.role == "user":
            return _parse_iso(turn.created_at)
    return None


def _candidate_topic(session: Session) -> str:
    for turn in reversed(session.turns):
        if turn.role == "user" and (turn.content or "").strip():
            return turn.content.strip()[:80]
    return "聊聊"


def _compose_reply(*, intent: str, topic: str, chat_mode: str, scenario: str) -> str:
    if intent == "continue_yesterday":
        return f"对了，{topic}…后来怎么样了？"
    if intent == "gentle_checkin":
        if scenario == "empty":
            return "在吗？想随便聊两句。"
        return f"还在吗？要接着聊{topic}吗？"
    if chat_mode == "companion":
        return f"我想起{topic}了，你最近怎么样？"
    return f"关于{topic}，你还有什么想聊的吗？"


class ProactivePipeline:
    def __init__(self, nn: Any) -> None:
        self._nn = nn
        self._sessions = nn.chat.sessions
        self._home = nn._home
        self._config = nn._config

    def get_profile_bundle(
        self,
        session: Session,
        *,
        user_id: str,
    ) -> dict[str, Any]:
        chat_mode = _effective_chat_mode(session)
        scope = resolve_proactive_scope(session.meta, chat_mode, self._home, user_id)
        profile = load_proactive_profile(scope)
        budget = load_token_budget(self._home, user_id)
        return {
            "scope": {
                "primary_project": scope.primary_project,
                "wiki_namespace": scope.wiki_namespace,
                "chat_mode": scope.chat_mode,
                "vp_id": scope.vp_id,
            },
            "profile": profile,
            "burst": resolve_proactive_chat_burst(budget),
            "disturb_lock": resolve_proactive_disturb_lock(budget),
            "enabled": bool(budget.get("proactive_chat_enabled")),
        }

    def reset_profile(self, session: Session, *, user_id: str) -> None:
        chat_mode = _effective_chat_mode(session)
        scope = resolve_proactive_scope(session.meta, chat_mode, self._home, user_id)
        from niannian.chat.proactive_profile import DEFAULT_PROFILE

        save_proactive_profile(scope, {**DEFAULT_PROFILE})

    def apply_feedback(
        self,
        session_id: str,
        turn_id: str,
        *,
        user_id: str,
        rating: str,
        reason: str | None = None,
    ) -> dict[str, Any]:
        from niannian.chat.enrichment_actions import find_turn
        from niannian.chat.proactive_profile import apply_proactive_feedback
        from niannian.chat.session import TurnNotFoundError

        session = self._sessions.require(session_id)
        try:
            turn = find_turn(session, turn_id)
        except TurnNotFoundError as exc:
            raise exc
        if turn.role != "assistant" or not turn.meta.get("proactive"):
            raise ValueError("not_proactive_turn")
        chat_mode = _effective_chat_mode(session)
        scope = resolve_proactive_scope(session.meta, chat_mode, self._home, user_id)
        profile = load_proactive_profile(scope)
        topic = str(turn.meta.get("proactive_topic") or "").strip() or None
        if str(reason or "").strip().lower() == "repeat" and rating == "quiet" and not topic:
            topic = _candidate_topic(session)
        updated = apply_proactive_feedback(
            scope,
            profile,
            rating=rating,
            topic=topic if rating == "quiet" else None,
        )
        return {"ok": True, "profile": updated}

    def prepare(self, session_id: str, *, user_id: str, body: ProactiveRequest) -> ProactivePrepareResult:
        session = self._sessions.require(session_id)
        self._assert_gates(session, user_id)
        self._assert_rate_limits(session, user_id)
        correction = self._build_correction(session, user_id, body)
        if correction.hold_back:
            return ProactivePrepareResult(hold_back=True, reason=correction.tone_bias)
        return ProactivePrepareResult(hold_back=False)

    def run_stream(
        self,
        session_id: str,
        *,
        user_id: str,
        body: ProactiveRequest,
    ) -> Iterator[tuple[str, dict[str, Any]]]:
        session = self._sessions.require(session_id)
        self._sessions.acquire_turn_lock(session_id)
        try:
            self._assert_gates(session, user_id)
            self._assert_rate_limits(session, user_id)
            correction = self._build_correction(session, user_id, body)
            if correction.hold_back:
                return

            chat_mode = _effective_chat_mode(session)
            scenario = body.scenario or _session_scenario(session)
            topic = correction.topic or _candidate_topic(session)
            reply = _compose_reply(
                intent=correction.preferred_intent,
                topic=topic,
                chat_mode=chat_mode,
                scenario=scenario,
            )

            budget = load_token_budget(self._home, user_id)
            sticker_pick = None
            if chat_sticker_allows(budget, "assistant"):
                equipped_vp_id = session.meta.get("equipped_vp_id")
                vp_id = str(equipped_vp_id).strip() if equipped_vp_id is not None else None
                sticker_pick = pick_assistant_sticker(
                    user_text=topic,
                    reply=reply,
                    chat_mode=chat_mode,
                    level=resolve_chat_sticker_level(budget),
                    regime=str(session.meta.get("regime") or session.meta.get("regime_lock") or ""),
                    persona_preset=str(
                        (session.meta.get("persona_override") or {}).get("preset") or ""
                    )
                    or None,
                    vp_id=vp_id or None,
                    home=self._home,
                    user_id=user_id,
                )

            turn_id = uuid4().hex[:16]
            meta: dict[str, Any] = {
                "proactive": True,
                "proactive_scenario": scenario,
                "proactive_intent": correction.preferred_intent,
                "proactive_topic": topic,
                "proactive_scope": resolve_proactive_scope(
                    session.meta, chat_mode, self._home, user_id
                ).primary_project,
            }
            if sticker_pick:
                meta["sticker_id"] = sticker_pick.sticker_id
                pack = str(sticker_pick.pack or "")
                meta["sticker_pack"] = pack if pack.startswith("vp:") else STICKER_PACK_ID
                meta["label"] = sticker_pick.label
                meta["sticker_proactive"] = True

            final_reply = reply
            if sticker_pick and sticker_pick.sticker_only:
                final_reply = ""

            turn = Turn(
                id=turn_id,
                role="assistant",
                content=final_reply,
                created_at=_now_iso(),
                meta=meta,
            )
            self._sessions.append_turn(session_id, turn)
            self._decrement_burst(session, user_id)

            yield ("delta", {"text": final_reply or reply})
            if sticker_pick:
                yield (
                    "sticker",
                    {
                        "sticker_id": sticker_pick.sticker_id,
                        "label": sticker_pick.label,
                        "sticker_only": sticker_pick.sticker_only,
                    },
                )
            yield (
                "done",
                {
                    "turn_id": turn_id,
                    "proactive": True,
                    "scenario": scenario,
                    "ingested": False,
                },
            )
        finally:
            self._sessions.release_turn_lock(session_id)

    def _assert_gates(self, session: Session, user_id: str) -> None:
        budget = load_token_budget(self._home, user_id)
        if not budget.get("proactive_chat_enabled"):
            raise ProactiveForbidden("proactive_disabled")
        if not chat_llm_allows(budget, "reply"):
            raise ProactiveForbidden("chat_llm_off")
        if _effective_chat_mode(session) == "work":
            raise ProactiveForbidden("work_mode")

    def _assert_rate_limits(self, session: Session, user_id: str) -> None:
        budget = load_token_budget(self._home, user_id)
        state = dict(session.meta.get("proactive_state") or {})
        now = datetime.now(timezone.utc)
        last_at = _parse_iso(state.get("last_proactive_at"))
        if last_at is not None:
            gap = (now - last_at).total_seconds()
            if gap < MIN_PROACTIVE_INTERVAL_SEC:
                raise ProactiveRateLimited("too_soon")

        burst = state.get("burst_remaining")
        if burst is None:
            vp_policy = self._vp_policy(session, user_id)
            burst = initial_burst_count(
                resolve_proactive_chat_burst(budget),
                _effective_chat_mode(session),
                vp_policy,
            )
            state["burst_remaining"] = burst
            session.meta["proactive_state"] = state

        if int(state.get("burst_remaining") or 0) <= 0:
            raise ProactiveRateLimited("burst_exhausted")

    def _decrement_burst(self, session: Session, user_id: str) -> None:
        state = dict(session.meta.get("proactive_state") or {})
        state["burst_remaining"] = max(0, int(state.get("burst_remaining") or 1) - 1)
        state["last_proactive_at"] = _now_iso()
        session.meta["proactive_state"] = state

    def _vp_policy(self, session: Session, user_id: str):
        vp_id = session.meta.get("equipped_vp_id")
        if not vp_id:
            return None
        try:
            row = VirtualPersonaStore(self._home, user_id).get(str(vp_id).strip())
            return load_vp_proactive_policy(row)
        except FileNotFoundError:
            return None

    def _build_proactive_skill_block(
        self,
        session: Session,
        *,
        chat_mode: str,
        user_id: str,
    ) -> str:
        loader = getattr(self._nn, "skills", None)
        if loader is None:
            return ""
        vp_id_raw = session.meta.get("equipped_vp_id")
        vp_id = str(vp_id_raw).strip() if vp_id_raw is not None else None
        equipped: list[str] = []
        if vp_id:
            try:
                equipped = read_equipped_skills(VirtualPersonaStore(self._home, user_id), vp_id)
            except FileNotFoundError:
                equipped = []
        return inject_skills(
            loader,
            "proactive",
            context=SkillInjectContext(
                mode=chat_mode,
                vp_id=vp_id,
                equipped_skill_names=equipped,
            ),
        )

    def _build_correction(
        self,
        session: Session,
        user_id: str,
        body: ProactiveRequest,
    ) -> CorrectionResult:
        chat_mode = _effective_chat_mode(session)
        scope = resolve_proactive_scope(session.meta, chat_mode, self._home, user_id)
        profile = load_proactive_profile(scope)
        profile = dict(profile)
        profile["proactive_disturb_lock"] = resolve_proactive_disturb_lock(
            load_token_budget(self._home, user_id)
        )
        topic = _candidate_topic(session)
        assembly_excerpt, asm_trace = load_proactive_assembly_excerpt(
            self._home, user_id, scope.vp_id
        )
        reflect = build_reflect_brief(
            scope,
            candidate_topic=topic,
            profile=profile,
            unfinished_yesterday=False,
            assembly_excerpt=assembly_excerpt,
        )
        associate = build_association_brief(scope, seeds=[topic], wiki_hits=[])
        del associate  # reserved for LLM prompt in later tuning
        candidates = p15_reflect_candidates(reflect)
        result = p16_correct(candidates, profile, self._vp_policy(session, user_id))
        skill_block = self._build_proactive_skill_block(session, chat_mode=chat_mode, user_id=user_id)
        trace = list(asm_trace)
        if skill_block:
            trace.append(f"proactive skills 注入 {len(skill_block)} 字符")
        return replace(
            result,
            assembly_excerpt=assembly_excerpt,
            skill_block=skill_block,
            trace=tuple(trace),
        )
