"""失败埋点(telemetry)—— 与计费(usage.py)分家的另一类 usage_events 写入。

judged by kind:这里的四类 cost 恒 0、tokens 不入 kind=chat 汇总,语义是**可观测性
留痕**而非用户花销 —— 它们是 admin「工具失败聚集」面板与巡检邮件(core/toolfail.py)
的唯一持久数据源。此前与真计费(chat/image/video/vision)混住 usage.py,污染
「usage=计费」的心智模型(架构审查数据层 Top4),2026-07-23 拆出。

kind 常量是 loop/llm_transport(写)与 toolfail(读 SQL)之间的契约单一事实源 ——
以前两侧靠字符串字面量约定,改名会静默漏读。
"""
from __future__ import annotations

from decimal import Decimal
from uuid import UUID

from .engine import session_scope
from .models import UsageEvent

# ── kind 契约常量(写侧本模块 / 读侧 core/toolfail.py 共用)──
KIND_TOOL_MALFORMED = "tool_malformed"
KIND_TOOL_SALVAGED = "tool_salvaged"
KIND_EMPTY_RESPONSE = "empty_response"
KIND_RUN_ERROR = "run_error"


def record_malformed_tool_call(
    *,
    task_id: UUID,
    user_id: UUID,
    model_profile: str,
    tool: str,
    arg_len: int,
    error: str,
    head: str,
    tail: str,
    tokens_in: int = 0,
    tokens_out: int = 0,
) -> None:
    """记一次被丢弃的畸形 tool_call(kind=tool_malformed)。

    这不是记账而是留痕:畸形轮整轮丢弃、messages 无痕,此行是 admin「工具失败聚集」
    面板与巡检邮件唯一的数据源(core/toolfail.py 第二段扫描按 units->>'tool'/'err'
    聚合)。cost_cny 恒 0 —— cost 统计全 kind 合计且随任务展示,provider 抖动的浪费
    不该算成用户花销;该轮真实 token 快照进 units,将来要算浪费成本可从 units 反推
    (token 汇总只算 kind=chat,此处 tokens_in/out 不会混进 token 统计)。
    """
    with session_scope() as s:
        s.add(UsageEvent(
            user_id=user_id,
            task_id=task_id,
            message_id=None,  # 畸形轮不入 messages,无可关联
            kind=KIND_TOOL_MALFORMED,
            model_profile=model_profile,
            units={
                "tool": tool,
                "len": int(arg_len),
                "err": str(error)[:200],
                "head": head,
                "tail": tail,
                "tokens_in": int(tokens_in),
                "tokens_out": int(tokens_out),
            },
            cost_cny=Decimal("0"),
        ))


def record_salvaged_tool_call(
    *,
    task_id: UUID,
    user_id: UUID,
    model_profile: str,
    tool: str,
    arg_len: int,
    salvaged_len: int,
    head: str,
) -> None:
    """记一次从畸形 arguments 里抢救成功的 tool_call(kind=tool_salvaged)。

    与 record_malformed_tool_call 是一对:畸形轮本会整轮丢弃 + 非流式重 roll,salvage
    命中时改成就地抠出尾部完好 JSON、当轮照常执行,省掉一次重试。此行是留痕(cost 恒 0,
    tokens 不入 kind=chat 汇总),供「工具失败聚集」面板 / 事后核对区分「真丢弃」与「抢救回」:
    arg_len=损坏前缀+JSON 的总长,salvaged_len=抠出 JSON 的长,差值即被丢弃的 wire 垃圾前缀长。
    head 存前 300 字(过 ascii 转义,消费端编码不可控)供核验前缀形态是否仍是 char-0 型。
    """
    with session_scope() as s:
        s.add(UsageEvent(
            user_id=user_id,
            task_id=task_id,
            message_id=None,  # 抢救出的 tool_call 正常执行并入 messages,但此留痕行不与之关联
            kind=KIND_TOOL_SALVAGED,
            model_profile=model_profile,
            units={
                "tool": tool,
                "len": int(arg_len),
                "salvaged_len": int(salvaged_len),
                "head": head,
            },
            cost_cny=Decimal("0"),
        ))


def record_empty_response(
    *,
    task_id: UUID,
    user_id: UUID,
    model_profile: str,
    attempt: int,
    tokens_in: int = 0,
    tokens_out: int = 0,
    finish_reason: str = "",
) -> None:
    """记一次 provider 吐空(kind=empty_response):assistant 轮既无 tool_calls 又无正文。

    背景(task 2a1bc25d 案):unifyllm 网关对某档 Claude 偶发把 tool_use 漏成正文 / 直接吐空,
    run loop 见 tool_calls 空即当「模型答完」静默 done —— 无报错、run_status=idle,只能人肉
    挖 DB 才发现。此行是留痕(cost 恒 0,tokens 不入 kind=chat 汇总):即使当轮非流式重试救回,
    也留一条供「工具失败聚集」面板看到「哪个模型档在吐空」(第四段扫描,tool 名固定 "(empty)")。
    attempt=第几次尝试(1=首个流式轮),tokens 快照供估算浪费。单条转瞬即逝不触发面板(阈值
    min_count/min_tasks),跨 task 系统性吐空才冒头 —— 正是要抓的网关级抖动。
    """
    with session_scope() as s:
        s.add(UsageEvent(
            user_id=user_id,
            task_id=task_id,
            message_id=None,  # 空响应轮不入 messages(丢弃重试),无可关联
            kind=KIND_EMPTY_RESPONSE,
            model_profile=model_profile,
            units={
                "attempt": int(attempt),
                "tokens_in": int(tokens_in),
                "tokens_out": int(tokens_out),
                # finish_reason 区分「网关 wire 吐空」(stop/其他)与「输出达上限被截断」
                # (length)—— 后者是我方输出预算/推理失控,同上下文重试无效(见 loop 处理)。
                "finish_reason": finish_reason or "",
            },
            cost_cny=Decimal("0"),
        ))


def record_run_error(
    *,
    task_id: UUID,
    user_id: UUID,
    model_profile: str,
    error: str,
) -> None:
    """记一次 run 级终态错误(kind=run_error)。

    留痕而非记账(cost 恒 0):run 在 LLM 请求层/构建期直接抛异常(RateLimitError
    余额不足、认证失败等)时,整轮没有 tool 消息,错误只写 tasks.run_error 一列 ——
    而该列只留最后一次,下次成功即被清掉。此行是「工具失败聚集」面板 + 巡检邮件
    看到 run 级错误的唯一持久数据源(core/toolfail.py 第三段扫描按 units->>'err'
    聚合)。task 2a1bc25d 案:Zai 余额不足连挂 3 次续跑,前端无提示、admin 无感知。
    """
    with session_scope() as s:
        s.add(UsageEvent(
            user_id=user_id,
            task_id=task_id,
            message_id=None,  # 错误轮通常无 assistant message 落库,无可关联
            kind=KIND_RUN_ERROR,
            model_profile=model_profile,
            units={"err": str(error)[:500]},
            cost_cny=Decimal("0"),
        ))
