from __future__ import annotations

import logging
from collections import defaultdict
from datetime import datetime
from typing import Any, Optional

from app.features.personalized_reco.content_repository.interface import ContentRepository
from app.features.personalized_reco.content_repository.types import ContentProfileDTO, normalize_locale
from app.features.personalized_reco.observability.builder import RecoMetaBuilder
from app.features.personalized_reco.reco_engine.defaults import get_default_engine_config
from app.features.personalized_reco.reco_engine.hard_filter import hard_filter
from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineConfig, RecoEngineResult, RecommendedItem, Scene
from app.features.personalized_reco.reco_engine.utils import (
    clamp_personalization_power,
    merge_exclude_ids,
    normalize_or_default_locale,
)
from app.features.personalized_reco.rerank_freqcap.rerank import rerank_and_freqcap
from app.features.personalized_reco.rerank_freqcap.types import ScoredCandidate
from app.features.personalized_reco.scoring.defaults import get_default_config as get_default_score_config
from app.features.personalized_reco.scoring.score import score_content

logger = logging.getLogger(__name__)


def _safe_int(value: Any, *, default: int = 0) -> int:
    try:
        n = int(value)
    except Exception:
        return int(default)
    return int(n)


def _light_score_summary(score_result: Any) -> dict[str, Any]:
    """
    轻量 explanations：只保留少量关键字段，避免 payload 过大。
    """

    bd = getattr(score_result, "breakdown", None)
    if bd is None:
        return {}

    def _get(name: str) -> Optional[float]:
        v = getattr(bd, name, None)
        if v is None:
            return None
        try:
            f = float(v)
        except Exception:
            return None
        if f != f:
            return None
        return f

    out: dict[str, Any] = {
        "missing_fields": list(getattr(bd, "missing_fields", []) or []),
        "S_core": _get("S_core"),
        "S_personal": _get("S_personal"),
        "P_uncertainty": _get("P_uncertainty"),
        "P_risk": _get("P_risk"),
        "P_widget_emotion_out_of_range": _get("P_widget_emotion_out_of_range"),
    }
    # 删除 None，减少噪音
    return {k: v for k, v in out.items() if v is not None and v != []}


def _apply_fallback_level_to_content(content: ContentProfileDTO, *, fallback_level: int) -> ContentProfileDTO:
    """
    对内容做防御式一致性处理（与回退梯度一致）。
    """

    p2 = clamp_personalization_power(content.personalization_power, fallback_level=fallback_level)
    if p2 == content.personalization_power:
        return content
    return content.model_copy(update={"personalization_power": float(p2)})


def _merge_counter(dst: dict[str, int], src: dict[str, Any]) -> None:
    for k, v in (src or {}).items():
        try:
            n = int(v)
        except Exception:
            n = 0
        dst[str(k)] = int(dst.get(str(k), 0)) + max(0, int(n))


async def recommend(
    *,
    repo: ContentRepository,
    scene: Scene,
    user_profile: object,
    already_recommended_ids: list[Any],
    touched_or_viewed_ids: list[Any],
    k: int,
    now: datetime,
    locale: Optional[str] = None,
    constraints: Optional[RecoConstraints] = None,
    config: Optional[RecoEngineConfig] = None,
) -> RecoEngineResult:
    """
    Reco Engine 主入口：编排候选→过滤→打分→重排→回退，并输出 items + meta。
    """

    cfg = config or get_default_engine_config(scene)
    cons = constraints or RecoConstraints()

    k_i = max(0, _safe_int(k, default=0))
    meta_builder = RecoMetaBuilder(scene=scene, user_profile=user_profile, k=k_i, now=now)

    if k_i <= 0:
        meta_builder.set_candidate_pool_size_raw(0).set_after_hard_filter(0).set_after_dedup(0).set_after_freqcap(0).set_served_k(0).set_fallback_level_final(0)
        meta_builder.set_config_snapshot({"engine_note": "k<=0，直接返回空结果"})
        return RecoEngineResult(items=[], meta=meta_builder.build())

    # locale：默认 en；严格校验仅支持 en/tc
    raw_locale = normalize_or_default_locale(locale)
    try:
        effective_locale = normalize_locale(raw_locale)
    except Exception as e:
        meta_builder.set_config_snapshot({"error": str(e), "stage": "normalize_locale", "locale": raw_locale})
        meta_builder.set_candidate_pool_size_raw(0).set_after_hard_filter(0).set_after_dedup(0).set_after_freqcap(0).set_served_k(0).set_fallback_level_final(0)
        return RecoEngineResult(items=[], meta=meta_builder.build())

    # 聚合统计（跨回退层级累加，确保 meta 单调性成立）
    raw_total = 0
    after_hard_total = 0
    after_dedup_total = 0
    after_freqcap_total = 0

    risk_counts_total: dict[str, int] = defaultdict(int)
    freqcap_counts_total: dict[str, int] = defaultdict(int)

    fallback_trace: list[dict[str, Any]] = []
    selected: list[ScoredCandidate] = []
    selected_level_by_id: dict[int, int] = {}

    last_fallback_level = 0
    last_reason = None

    for level in [0, 1, 2, 3]:
        last_fallback_level = int(level)
        k_remaining = max(0, k_i - len(selected))
        if k_remaining <= 0:
            break

        # Feed：允许不足且不补齐时，拿到任何结果就停止
        if scene == "feed" and cfg.feed_allow_partial and (not cfg.feed_fill_with_fallback) and len(selected) > 0:
            break

        # exclude_ids：already/touched + constraints.exclude + 已选内容（避免跨层重复）
        exclude_ids = merge_exclude_ids(
            already_recommended_ids=list(already_recommended_ids or []) + [int(x.content_id) for x in selected],
            touched_or_viewed_ids=list(touched_or_viewed_ids or []),
            extra_exclude_content_ids=list(cons.exclude_content_ids or []),
        )

        multiplier = int(cfg.candidate_multiplier_feed if scene == "feed" else cfg.candidate_multiplier_push_widget)
        base_limit = max(int(cfg.min_candidates_per_level), int(k_remaining) * max(1, int(multiplier)))
        if cons.max_candidates_limit is not None and int(cons.max_candidates_limit) > 0:
            limit = min(base_limit, int(cons.max_candidates_limit))
        else:
            limit = base_limit

        # 1) Candidate
        try:
            cands = await repo.fetch_candidates(
                scene=scene,
                user_profile=user_profile,
                fallback_level=int(level),
                limit=int(limit),
                locale=str(effective_locale),
                exclude_content_ids=exclude_ids,
            )
        except Exception as e:
            logger.exception("fetch_candidates 失败：%s", e)
            last_reason = "error:fetch_candidates"
            fallback_trace.append(
                {
                    "level": int(level),
                    "raw": 0,
                    "after_hard": 0,
                    "after_dedup": 0,
                    "after_freqcap": 0,
                    "served_total": len(selected),
                    "error": str(e),
                }
            )
            continue

        raw_total += len(cands)

        if not cands:
            last_reason = "pool_empty"
            fallback_trace.append(
                {
                    "level": int(level),
                    "raw": 0,
                    "after_hard": 0,
                    "after_dedup": 0,
                    "after_freqcap": 0,
                    "served_total": len(selected),
                    "reason": "pool_empty",
                }
            )
            continue

        # 2) Hard Filter
        hf = hard_filter(scene=scene, user_profile=user_profile, candidates=cands, constraints=cons)
        kept = [x for x in hf.kept_items if isinstance(x, ContentProfileDTO)]
        after_hard_total += len(kept)
        _merge_counter(risk_counts_total, hf.risk_filtered_count_by_flag)

        if not kept:
            last_reason = "hard_filter_all"
            fallback_trace.append(
                {
                    "level": int(level),
                    "raw": len(cands),
                    "after_hard": 0,
                    "after_dedup": 0,
                    "after_freqcap": 0,
                    "served_total": len(selected),
                    "reason": "hard_filter_all",
                }
            )
            continue

        # 3) Soft Scoring
        score_cfg = get_default_score_config(scene)
        if scene == "push":
            # Push：强制启用不确定性惩罚（与 spec 对齐）
            score_cfg = score_cfg.model_copy(update={"enable_uncertainty_penalty": True})

        scored: list[ScoredCandidate] = []
        for c in kept:
            c2 = _apply_fallback_level_to_content(c, fallback_level=int(level))
            try:
                s = score_content(scene=scene, user_profile=user_profile, content_profile=c2, config=score_cfg, pass_filters=True, now=now)
            except Exception as e:
                # 单条异常不影响整体
                logger.exception("score_content 失败 content_id=%s：%s", getattr(c2, "content_id", None), e)
                continue

            cid = int(c2.content_id)
            hits = hf.hits_by_content_id.get(cid, [])
            extra: dict[str, Any] = {
                "text": c2.text,
                "fallback_level_used": int(level),
            }
            if cfg.enable_explanations:
                extra["hard_filter_hits"] = hits
                extra["score_summary"] = _light_score_summary(s)

            scored.append(
                ScoredCandidate(
                    content_id=cid,
                    final_score=float(getattr(s, "final_score", 0.0)),
                    author_id=c2.author_id,
                    template_id=c2.template_id,
                    content_profile=c2,
                    extra=extra,
                )
            )

        if not scored:
            last_reason = "empty_after_scoring"
            fallback_trace.append(
                {
                    "level": int(level),
                    "raw": len(cands),
                    "after_hard": len(kept),
                    "after_dedup": 0,
                    "after_freqcap": 0,
                    "served_total": len(selected),
                    "reason": "empty_after_scoring",
                }
            )
            continue

        # 4) Rerank/Freqcap
        try:
            rer = rerank_and_freqcap(
                scene=scene,
                scored_candidates=scored,
                already_recommended_ids=list(already_recommended_ids or []) + [int(x.content_id) for x in selected],
                touched_or_viewed_ids=list(touched_or_viewed_ids or []),
                k=int(k_remaining),
                recent_author_ids=cons.recent_author_ids,
                recent_template_ids=cons.recent_template_ids,
            )
        except Exception as e:
            logger.exception("rerank_and_freqcap 失败：%s", e)
            last_reason = "error:rerank_and_freqcap"
            fallback_trace.append(
                {
                    "level": int(level),
                    "raw": len(cands),
                    "after_hard": len(kept),
                    "after_dedup": 0,
                    "after_freqcap": 0,
                    "served_total": len(selected),
                    "error": str(e),
                }
            )
            continue

        after_dedup_total += int(rer.meta.candidate_pool_size_after_dedup)
        after_freqcap_total += int(rer.meta.candidate_pool_size_after_freqcap)
        _merge_counter(freqcap_counts_total, rer.meta.freqcap_filtered_counts)

        served_level = list(rer.ranked_items or [])[:k_remaining]
        if not served_level:
            last_reason = "freqcap_all"
            fallback_trace.append(
                {
                    "level": int(level),
                    "raw": len(cands),
                    "after_hard": len(kept),
                    "after_dedup": int(rer.meta.candidate_pool_size_after_dedup),
                    "after_freqcap": int(rer.meta.candidate_pool_size_after_freqcap),
                    "served_total": len(selected),
                    "reason": "freqcap_all",
                }
            )
            continue

        for it in served_level:
            cid = int(it.content_id)
            selected.append(it)
            selected_level_by_id[cid] = int(level)

        last_reason = None
        fallback_trace.append(
            {
                "level": int(level),
                "raw": len(cands),
                "after_hard": len(kept),
                "after_dedup": int(rer.meta.candidate_pool_size_after_dedup),
                "after_freqcap": int(rer.meta.candidate_pool_size_after_freqcap),
                "served_total": len(selected),
                "served_added": len(served_level),
            }
        )

        if len(selected) >= k_i:
            break

    # 组装输出 items（按 selected 顺序）
    items: list[RecommendedItem] = []
    for c in selected[:k_i]:
        cid = int(c.content_id)
        text = ""
        if isinstance(c.extra, dict):
            text = str(c.extra.get("text") or "")

        explanations = None
        if cfg.enable_explanations and isinstance(c.extra, dict):
            explanations = {
                "fallback_level_used": c.extra.get("fallback_level_used"),
                "hard_filter_hits": c.extra.get("hard_filter_hits"),
                "score_summary": c.extra.get("score_summary"),
            }

        items.append(
            RecommendedItem(
                content_id=cid,
                text=text,
                final_score=float(c.final_score),
                fallback_level_final=int(selected_level_by_id.get(cid, last_fallback_level)),
                explanations=explanations,
            )
        )

    served_k = len(items)

    # meta：使用聚合统计，确保单调性约束成立（raw>=after_hard>=after_dedup>=after_freqcap>=served_k）
    # 注意：聚合统计理论上可能出现 after_* > raw_total（例如 repo 返回重复/异常），此处交由 builder 防御修正
    meta_builder.set_candidate_pool_size_raw(int(raw_total))
    meta_builder.set_after_hard_filter(int(after_hard_total), risk_filtered_count_by_flag=dict(risk_counts_total))
    meta_builder.set_after_dedup(int(after_dedup_total))
    meta_builder.set_after_freqcap(int(after_freqcap_total), freqcap_filtered_counts=dict(freqcap_counts_total))
    meta_builder.set_served_k(int(served_k))
    meta_builder.set_fallback_level_final(int(last_fallback_level), reason=last_reason)

    meta_builder.set_config_snapshot(
        {
            "fallback_trace": fallback_trace,
            "engine_config": cfg.model_dump(),
            "constraints": cons.model_dump(),
            "locale": effective_locale,
        }
    )

    return RecoEngineResult(items=items, meta=meta_builder.build())

