"""find_agent P0 候选硬门槛与基础时效规则。"""
from __future__ import annotations

import json
import re
from dataclasses import asdict, dataclass
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from typing import Any, Mapping
from zoneinfo import ZoneInfo

from supply_infra.config import get_infra_settings


@dataclass(frozen=True)
class FindAgentGateRules:
    rule_version: str
    timezone: str
    min_duration_seconds: int
    min_share_count: int
    min_content_50_plus_ratio: float
    min_account_50_plus_ratio: float
    festival_lead_days: int
    event_max_age_days: int
    seasonal_max_age_days: int


_DAYPART_RULES: tuple[tuple[str, tuple[str, ...], time, time], ...] = (
    ("morning", ("早上好", "早安", "晨安", "早晨好"), time(5, 0), time(10, 30)),
    ("noon", ("午安", "中午好", "午间好"), time(11, 0), time(14, 0)),
    ("evening", ("晚上好", "晚安", "夜安"), time(18, 0), time(1, 0)),
)

_FIXED_FESTIVALS: dict[str, tuple[int, int]] = {
    "元旦": (1, 1),
    "情人节": (2, 14),
    "妇女节": (3, 8),
    "劳动节": (5, 1),
    "儿童节": (6, 1),
    "建党节": (7, 1),
    "建军节": (8, 1),
    "教师节": (9, 10),
    "国庆": (10, 1),
    "国庆节": (10, 1),
    "圣诞": (12, 25),
    "圣诞节": (12, 25),
}

# 业务运行期内使用的农历节日阳历日期。超出覆盖年份时返回 unknown，不猜测日期。
_LUNAR_FESTIVALS: dict[int, dict[str, tuple[int, int]]] = {
    2025: {
        "春节": (1, 29),
        "元宵": (2, 12),
        "元宵节": (2, 12),
        "端午": (5, 31),
        "端午节": (5, 31),
        "七夕": (8, 29),
        "中秋": (10, 6),
        "中秋节": (10, 6),
        "重阳": (10, 29),
        "重阳节": (10, 29),
    },
    2026: {
        "春节": (2, 17),
        "元宵": (3, 3),
        "元宵节": (3, 3),
        "端午": (6, 19),
        "端午节": (6, 19),
        "七夕": (8, 19),
        "中秋": (9, 25),
        "中秋节": (9, 25),
        "重阳": (10, 18),
        "重阳节": (10, 18),
    },
    2027: {
        "春节": (2, 6),
        "元宵": (2, 20),
        "元宵节": (2, 20),
        "端午": (6, 9),
        "端午节": (6, 9),
        "七夕": (8, 8),
        "中秋": (9, 15),
        "中秋节": (9, 15),
        "重阳": (10, 8),
        "重阳节": (10, 8),
    },
}

_EVENT_MARKERS = (
    "最新消息",
    "突发",
    "刚刚",
    "今日新闻",
    "赛事结果",
    "比赛结果",
)
_RELATIVE_DATE_MARKERS = ("今天", "今日", "明天", "昨日", "昨天")


def get_gate_rules() -> FindAgentGateRules:
    settings = get_infra_settings()
    return FindAgentGateRules(
        rule_version=settings.find_agent_rule_version,
        timezone=settings.scheduler_timezone,
        min_duration_seconds=settings.find_agent_min_duration_seconds,
        min_share_count=settings.find_agent_min_share_count,
        min_content_50_plus_ratio=settings.find_agent_min_content_50_plus_ratio,
        min_account_50_plus_ratio=settings.find_agent_min_account_50_plus_ratio,
        festival_lead_days=settings.find_agent_festival_lead_days,
        event_max_age_days=settings.find_agent_event_max_age_days,
        seasonal_max_age_days=settings.find_agent_seasonal_max_age_days,
    )


def build_rule_snapshot(now: datetime | None = None) -> dict[str, Any]:
    rules = get_gate_rules()
    timezone = ZoneInfo(rules.timezone)
    evaluated_at = now or datetime.now(timezone)
    if evaluated_at.tzinfo is None:
        evaluated_at = evaluated_at.replace(tzinfo=timezone)
    else:
        evaluated_at = evaluated_at.astimezone(timezone)
    return {
        **asdict(rules),
        "current_datetime": evaluated_at.isoformat(timespec="seconds"),
        "current_date": evaluated_at.strftime("%Y-%m-%d"),
    }


def load_rule_snapshot(value: Mapping[str, Any] | str | None) -> dict[str, Any]:
    if isinstance(value, str):
        try:
            loaded = json.loads(value)
        except (TypeError, ValueError):
            loaded = {}
    else:
        loaded = dict(value or {})
    fallback = build_rule_snapshot()
    return {**fallback, **loaded}


def parse_datetime_value(
    value: Any,
    *,
    timezone_name: str = "Asia/Shanghai",
) -> datetime | None:
    if value in (None, "") or isinstance(value, bool):
        return None
    timezone = ZoneInfo(timezone_name)
    if isinstance(value, datetime):
        parsed = value
    elif isinstance(value, date):
        parsed = datetime.combine(value, time.min)
    elif isinstance(value, (int, float, Decimal)):
        timestamp = float(value)
        if timestamp > 10_000_000_000:
            timestamp /= 1000
        try:
            parsed = datetime.fromtimestamp(timestamp, timezone)
        except (OSError, OverflowError, ValueError):
            return None
    else:
        text = str(value).strip()
        if not text:
            return None
        if re.fullmatch(r"\d{10,13}", text):
            return parse_datetime_value(int(text), timezone_name=timezone_name)
        normalized = text.replace("Z", "+00:00")
        parsed = None
        for candidate in (
            normalized,
            normalized.replace("/", "-"),
        ):
            try:
                parsed = datetime.fromisoformat(candidate)
                break
            except ValueError:
                continue
        if parsed is None:
            for pattern in ("%Y%m%d", "%Y-%m-%d", "%Y/%m/%d"):
                try:
                    parsed = datetime.strptime(text, pattern)
                    break
                except ValueError:
                    continue
        if parsed is None:
            return None
    if parsed.tzinfo is None:
        return parsed.replace(tzinfo=timezone)
    return parsed.astimezone(timezone)


def normalize_duration_seconds(value: Any, *, unit: str = "seconds") -> Decimal | None:
    if value in (None, "") or isinstance(value, bool):
        return None
    try:
        number = Decimal(str(value))
    except (ArithmeticError, ValueError):
        return None
    if unit == "milliseconds":
        number /= Decimal("1000")
    if number < 0:
        return None
    return number.quantize(Decimal("0.001"))


def _candidate_text(candidate: Mapping[str, Any]) -> str:
    fragments = [str(candidate.get("title") or "")]
    tags = candidate.get("tags")
    if tags is None:
        tags = candidate.get("tags_json")
    if isinstance(tags, str):
        try:
            loaded = json.loads(tags)
        except (TypeError, ValueError):
            loaded = tags
        tags = loaded
    if isinstance(tags, list):
        fragments.extend(str(item) for item in tags)
    elif tags:
        fragments.append(str(tags))
    evidence = candidate.get("temporal_evidence")
    if evidence is None:
        evidence = candidate.get("temporal_evidence_json")
    if evidence:
        fragments.append(str(evidence))
    return " ".join(fragments)


def _time_in_window(current: time, start: time, end: time) -> bool:
    if start <= end:
        return start <= current <= end
    return current >= start or current <= end


def _festival_dates(year: int) -> dict[str, date]:
    result = {
        name: date(year, month, day)
        for name, (month, day) in _FIXED_FESTIVALS.items()
    }
    for name, (month, day) in _LUNAR_FESTIVALS.get(year, {}).items():
        result[name] = date(year, month, day)
    return result


def _nearest_festival_date(name: str, current: date) -> date | None:
    candidates = [
        festival_date
        for year in (current.year - 1, current.year, current.year + 1)
        if (festival_date := _festival_dates(year).get(name)) is not None
    ]
    if not candidates:
        return None
    return min(candidates, key=lambda value: abs((value - current).days))


def evaluate_temporal_status(
    candidate: Mapping[str, Any],
    rule_snapshot: Mapping[str, Any] | str | None,
) -> dict[str, Any]:
    rules = load_rule_snapshot(rule_snapshot)
    timezone_name = str(rules["timezone"])
    timezone = ZoneInfo(timezone_name)
    current = parse_datetime_value(
        rules.get("current_datetime"),
        timezone_name=timezone_name,
    ) or datetime.now(timezone)
    publish_at = parse_datetime_value(
        candidate.get("publish_at"),
        timezone_name=timezone_name,
    )
    text = _candidate_text(candidate)
    inferred_type = str(candidate.get("temporal_type") or "").strip() or "evergreen"
    evidence: dict[str, Any] = {
        "evaluated_at": current.isoformat(timespec="seconds"),
        "publish_at": publish_at.isoformat(timespec="seconds") if publish_at else None,
        "matched_terms": [],
    }

    if publish_at is None:
        if _can_compensate_temporal_unknown(candidate, text=text):
            return {
                "temporal_type": inferred_type or "evergreen",
                "status": "pass",
                "reason_code": None,
                "reason": "缺少发布时间，但无时效敏感表述且其他指标支持通过",
                "evidence": {**evidence, "compensated": True},
            }
        return {
            "temporal_type": inferred_type,
            "status": "unknown",
            "reason_code": "TEMPORAL_UNKNOWN",
            "reason": "缺少真实发布时间，无法完成时效校验",
            "evidence": evidence,
        }

    for daypart, terms, start, end in _DAYPART_RULES:
        matched = [term for term in terms if term in text]
        if not matched:
            continue
        inferred_type = "daypart"
        evidence["matched_terms"].extend(matched)
        evidence["valid_time"] = (
            f"{start.strftime('%H:%M')}-{end.strftime('%H:%M')}"
        )
        if not _time_in_window(current.timetz().replace(tzinfo=None), start, end):
            return {
                "temporal_type": inferred_type,
                "status": "fail",
                "reason_code": "DAYPART_EXPIRED",
                "reason": f"当前时间不在{daypart}内容有效时段",
                "evidence": evidence,
            }

    matched_festivals: list[tuple[str, date]] = []
    known_names = set(_FIXED_FESTIVALS)
    for yearly in _LUNAR_FESTIVALS.values():
        known_names.update(yearly)
    for name in sorted(known_names, key=len, reverse=True):
        if name not in text:
            continue
        festival_date = _nearest_festival_date(name, current.date())
        if festival_date is None:
            return {
                "temporal_type": "festival",
                "status": "unknown",
                "reason_code": "TEMPORAL_UNKNOWN",
                "reason": f"规则版本未覆盖当前相邻年份的{name}日期",
                "evidence": {**evidence, "matched_terms": [name]},
            }
        matched_festivals.append((name, festival_date))
        break
    if matched_festivals:
        inferred_type = "festival"
        name, festival_date = matched_festivals[0]
        lead_days = int(rules["festival_lead_days"])
        valid_from = festival_date - timedelta(days=lead_days)
        valid_to = festival_date + timedelta(days=1)
        evidence.update(
            {
                "matched_terms": [name],
                "valid_from": valid_from.isoformat(),
                "valid_to": valid_to.isoformat(),
            }
        )
        if not valid_from <= current.date() <= valid_to:
            return {
                "temporal_type": inferred_type,
                "status": "fail",
                "reason_code": "FESTIVAL_OUT_OF_WINDOW",
                "reason": f"{name}内容不在当前有效窗口",
                "evidence": evidence,
            }

    matched_relative = [term for term in _RELATIVE_DATE_MARKERS if term in text]
    if matched_relative and publish_at.date() != current.date():
        evidence["matched_terms"].extend(matched_relative)
        return {
            "temporal_type": "event",
            "status": "fail",
            "reason_code": "RELATIVE_DATE_EXPIRED",
            "reason": "内容包含相对日期表述，但并非当天发布",
            "evidence": evidence,
        }

    if any(marker in text for marker in _EVENT_MARKERS):
        inferred_type = "event"
    age_days = max(0.0, (current - publish_at).total_seconds() / 86400)
    evidence["content_age_days"] = round(age_days, 3)
    if inferred_type == "event" and age_days > int(rules["event_max_age_days"]):
        return {
            "temporal_type": inferred_type,
            "status": "fail",
            "reason_code": "EVENT_EXPIRED",
            "reason": "事件型内容超过允许的新鲜度窗口",
            "evidence": evidence,
        }
    if (
        inferred_type == "seasonal"
        and age_days > int(rules["seasonal_max_age_days"])
    ):
        return {
            "temporal_type": inferred_type,
            "status": "fail",
            "reason_code": "SEASONAL_EXPIRED",
            "reason": "季节型内容超过允许的新鲜度窗口",
            "evidence": evidence,
        }

    explicit_status = str(candidate.get("temporal_status") or "").strip()
    if explicit_status in {"fail", "unknown"}:
        reason_code = (
            "TEMPORAL_EXPIRED" if explicit_status == "fail" else "TEMPORAL_UNKNOWN"
        )
        return {
            "temporal_type": inferred_type,
            "status": explicit_status,
            "reason_code": reason_code,
            "reason": "候选补证结果未通过时间有效性判断",
            "evidence": evidence,
        }
    return {
        "temporal_type": inferred_type,
        "status": "pass",
        "reason_code": None,
        "reason": "发布时间与基础时间语义有效",
        "evidence": evidence,
    }


def _number(value: Any) -> float | None:
    if value in (None, "") or isinstance(value, bool):
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _semantic_score(candidate: Mapping[str, Any], field: str) -> float | None:
    return _number(candidate.get(field))


def _has_strong_semantic_scores(candidate: Mapping[str, Any]) -> bool:
    """R/E/S 综合优秀，或 V 足够高，可用于缺失字段补偿。"""
    relevance = _semantic_score(candidate, "relevance_score")
    elder = _semantic_score(candidate, "elder_score")
    share = _semantic_score(candidate, "share_score")
    value = _semantic_score(candidate, "value_score")
    if (
        relevance is not None
        and elder is not None
        and share is not None
        and relevance >= 0.70
        and elder >= 0.70
        and share >= 0.65
    ):
        return True
    return value is not None and value >= 0.65


def _text_has_temporal_markers(text: str) -> bool:
    for _, terms, _, _ in _DAYPART_RULES:
        if any(term in text for term in terms):
            return True
    known_names = set(_FIXED_FESTIVALS)
    for yearly in _LUNAR_FESTIVALS.values():
        known_names.update(yearly)
    if any(name in text for name in known_names):
        return True
    if any(marker in text for marker in _EVENT_MARKERS + _RELATIVE_DATE_MARKERS):
        return True
    return False


def _can_compensate_missing_portrait(
    candidate: Mapping[str, Any],
    account_ratio: float | None,
    *,
    min_account_ratio: float,
) -> bool:
    if account_ratio is not None and account_ratio >= max(min_account_ratio, 0.35):
        return True
    if account_ratio is not None and account_ratio >= min_account_ratio:
        return _has_strong_semantic_scores(candidate)
    elder = _semantic_score(candidate, "elder_score")
    return elder is not None and elder >= 0.75 and _has_strong_semantic_scores(candidate)


def _can_compensate_missing_duration(
    candidate: Mapping[str, Any],
    shares: float | None,
    *,
    min_share_count: float,
) -> bool:
    if shares is not None and shares >= min_share_count * 1.5:
        return True
    relevance = _semantic_score(candidate, "relevance_score")
    return (
        relevance is not None
        and relevance >= 0.80
        and _has_strong_semantic_scores(candidate)
    )


def _can_compensate_missing_shares(candidate: Mapping[str, Any]) -> bool:
    likes = _number(candidate.get("like_count"))
    plays = _number(candidate.get("play_count"))
    if likes is not None and likes >= 5000:
        return True
    if plays is not None and plays >= 50000:
        return True
    share = _semantic_score(candidate, "share_score")
    return share is not None and share >= 0.75 and _has_strong_semantic_scores(candidate)


def _can_compensate_temporal_unknown(
    candidate: Mapping[str, Any],
    *,
    text: str,
) -> bool:
    if _text_has_temporal_markers(text):
        return False
    if _has_strong_semantic_scores(candidate):
        return True
    shares = _number(candidate.get("share_count"))
    return shares is not None and shares >= 2000


def evaluate_candidate_gate(
    candidate: Mapping[str, Any],
    rule_snapshot: Mapping[str, Any] | str | None,
) -> dict[str, Any]:
    rules = load_rule_snapshot(rule_snapshot)
    temporal = evaluate_temporal_status(candidate, rules)
    duration = _number(candidate.get("duration_seconds"))
    shares = _number(candidate.get("share_count"))
    content_ratio = _number(candidate.get("content_50_plus_ratio"))
    account_ratio = _number(candidate.get("account_50_plus_ratio"))

    checks: list[dict[str, Any]] = [
        {
            "name": "temporal",
            "status": temporal["status"],
            "reason_code": temporal["reason_code"],
            "actual": temporal["evidence"],
        }
    ]

    min_duration = float(rules["min_duration_seconds"])
    min_shares = float(rules["min_share_count"])
    min_content_ratio = float(rules["min_content_50_plus_ratio"])
    min_account_ratio = float(rules["min_account_50_plus_ratio"])

    def threshold_check(
        name: str,
        actual: float | None,
        threshold: float,
        *,
        missing_code: str,
        low_code: str,
        compensate_missing: bool = False,
    ) -> None:
        compensated = False
        if actual is None:
            if compensate_missing:
                status = "pass"
                reason_code = None
                compensated = True
            else:
                status = "fail"
                reason_code = missing_code
        elif actual < threshold:
            status = "fail"
            reason_code = low_code
        else:
            status = "pass"
            reason_code = None
        checks.append(
            {
                "name": name,
                "status": status,
                "reason_code": reason_code,
                "actual": actual,
                "threshold": threshold,
                "compensated": compensated,
            }
        )

    threshold_check(
        "duration_seconds",
        duration,
        min_duration,
        missing_code="DURATION_UNKNOWN",
        low_code="DURATION_TOO_SHORT",
        compensate_missing=_can_compensate_missing_duration(
            candidate,
            shares,
            min_share_count=min_shares,
        ),
    )
    threshold_check(
        "share_count",
        shares,
        min_shares,
        missing_code="SHARE_COUNT_UNKNOWN",
        low_code="SHARE_COUNT_TOO_LOW",
        compensate_missing=_can_compensate_missing_shares(candidate),
    )

    content_pass = (
        content_ratio is not None and content_ratio >= min_content_ratio
    )
    account_pass = (
        account_ratio is not None and account_ratio >= min_account_ratio
    )
    portrait_compensated = False
    if content_pass or account_pass:
        portrait_status = "pass"
        portrait_reason_code = None
    elif content_ratio is None and account_ratio is None:
        portrait_compensated = _can_compensate_missing_portrait(
            candidate,
            account_ratio,
            min_account_ratio=min_account_ratio,
        )
        if portrait_compensated:
            portrait_status = "pass"
            portrait_reason_code = None
        else:
            portrait_status = "fail"
            portrait_reason_code = "CONTENT_PORTRAIT_MISSING"
    else:
        portrait_status = "fail"
        portrait_reason_code = "PORTRAIT_50_PLUS_TOO_LOW"

    checks.append(
        {
            "name": "elder_portrait",
            "status": portrait_status,
            "reason_code": portrait_reason_code,
            "actual": {
                "content_50_plus_ratio": content_ratio,
                "account_50_plus_ratio": account_ratio,
            },
            "threshold": {
                "min_content_50_plus_ratio": min_content_ratio,
                "min_account_50_plus_ratio": min_account_ratio,
            },
            "compensated": portrait_compensated,
        }
    )

    failed_codes = [
        str(check["reason_code"])
        for check in checks
        if check["status"] != "pass" and check.get("reason_code")
    ]
    content_status = (
        "missing"
        if content_ratio is None
        else (
            "pass"
            if content_ratio >= float(rules["min_content_50_plus_ratio"])
            else "fail"
        )
    )
    account_status = (
        "missing"
        if account_ratio is None
        else (
            "pass"
            if account_ratio >= float(rules["min_account_50_plus_ratio"])
            else "fail"
        )
    )
    portrait_conflict = (
        content_status in {"pass", "fail"}
        and account_status in {"pass", "fail"}
        and content_status != account_status
    )
    return {
        "rule_version": str(rules["rule_version"]),
        "status": "pass" if not failed_codes else "fail",
        "primary_eligible": not failed_codes,
        "failed_reason_codes": failed_codes,
        "checks": checks,
        "temporal": temporal,
        "content_portrait_status": content_status,
        "account_portrait_status": account_status,
        "portrait_conflict": portrait_conflict,
    }
