"""媒体类工具共享原语(从 seedance/seedream/gpt_image/look_at_image/read_document
五处同构析出,2026-07-23)。

收进来的判据:**逐字或参数化后逐字**的重复 —— 每日配额闸、`<ts>-<rand6>` 落盘命名、
产物溯源元数据写入、记账 try/except 兜底、Ark 超时透明重试、chat 答案提取、响应递归找 URL。
各工具的首行 banner 格式 / 请求 body 组装 / seedance 轮询等有意各自不同,**不**收。
"""
from __future__ import annotations

import json
import secrets
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Optional
from uuid import UUID

from core.ark_client import ArkClient, ArkConfig, ArkError, ArkTimeoutError
from core.file_store import atomic_write_text
from core.storage.usage import check_daily_quota


def quota_gate(user_id: UUID, *, kind: str, limit: int, what: str, noun: str) -> Optional[str]:
    """每账号每日配额闸(yaml quotas.*_per_day)。超额返回 [Error] 文案,未超返 None。

    失败 retry 不计 —— record_*_usage 只在成功后才落库。tool 返串会进 LLM 上下文,
    模型据此向用户解释,所以**只暴露用户该看的部分**(已用/上限 + 重置时间),
    内部 yaml 路径不进对话。limit <= 0 = 不限。
    """
    if limit <= 0:
        return None
    used, over = check_daily_quota(user_id=user_id, kind=kind, limit=limit)
    if over:
        return (
            f"[Error] 已达每日{what}上限({used}/{limit} {noun}),"
            f"次日 00:00 重置。"
        )
    return None


def stamped_path(directory: Path, ext: str) -> Path:
    """产物落盘命名:`<dir>/<YYYYMMDD-HHMMSS>-<rand6><ext>`,顺手建目录。"""
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    short = secrets.token_hex(3)
    directory.mkdir(parents=True, exist_ok=True)
    return directory / f"{ts}-{short}{ext}"


def write_meta(dest: Path, meta: dict) -> None:
    """把 prompt/model/cost 等技术溯源信息写入产物旁默认隐藏的 `.meta/`。"""
    meta_path = dest.parent / ".meta" / f"{dest.name}.json"
    atomic_write_text(
        meta_path,
        json.dumps(meta, ensure_ascii=False, default=str, indent=2),
    )


def record_usage_safe(tool_name: str, fn: Callable, **kwargs) -> Any:
    """记账兜底:失败不阻塞 tool 返回(print 留痕),返回 fn 结果(失败返 None)。

    tool 层没 sink 引用,print 是现阶段的兜底;后续可改成 sink 注入。
    """
    try:
        return fn(**kwargs)
    except Exception as e:
        print(f"[{tool_name}] {getattr(fn, '__name__', 'record_usage')} failed: "
              f"{type(e).__name__}: {e}", flush=True)
        return None


def ark_chat_with_retry(
    ark_cfg: ArkConfig,
    endpoint: str,
    body: dict,
    *,
    timeout_s: float,
    retries: int,
    tool_name: str,
) -> tuple[Optional[dict], str]:
    """带超时透明重试的 Ark 单次 chat 调用。返回 (resp, err):成功 (dict, "");失败 (None, "[Error] ...")。

    重试只吃 ArkTimeoutError(超时/网络抖动),tool 内消化掉不把 [Error] 抛给主模型 ——
    否则主模型会重发整个 tool call(base64 重传、输入 token 再付一次)。HTTP 业务错误不重试。
    退避 2**attempt 秒;retries=0 即只调一次。
    """
    max_attempts = retries + 1
    for attempt in range(max_attempts):
        try:
            with ArkClient(ark_cfg, timeout_s=timeout_s) as client:
                return client.post_json(endpoint, body, timeout_s=timeout_s), ""
        except ArkTimeoutError as e:
            if attempt == max_attempts - 1:
                return None, f"[Error] {tool_name} API: {e}(已重试 {attempt} 次仍超时)"
            print(
                f"[{tool_name}] timeout, retrying ({attempt + 1}/{max_attempts - 1}): {e}",
                flush=True,
            )
            time.sleep(2 ** attempt)
        except ArkError as e:
            return None, f"[Error] {tool_name} API: {e}"
    return None, f"[Error] {tool_name} API: unreachable"  # pragma: no cover


def extract_chat_answer(resp: Optional[dict]) -> tuple[str, bool]:
    """OpenAI 兼容 chat 响应取文本 + 是否被输出上限截断(finish_reason=length)。

    content 可能是 str,也可能是 list[{type:text,text:...}](多模态返回形态),都兜住。
    """
    if not isinstance(resp, dict):
        return "", False
    choices = resp.get("choices")
    if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
        return "", False
    truncated = choices[0].get("finish_reason") == "length"
    msg = choices[0].get("message")
    if not isinstance(msg, dict):
        return "", truncated
    content = msg.get("content")
    if isinstance(content, str):
        return content.strip(), truncated
    if isinstance(content, list):
        parts = [
            c.get("text", "")
            for c in content
            if isinstance(c, dict) and c.get("type") == "text"
        ]
        return "\n".join(p for p in parts if p).strip(), truncated
    return "", truncated


def find_first_url(
    obj: Any,
    *,
    keys: tuple[str, ...],
    accept: Optional[Callable[[str, str], bool]] = None,
) -> str:
    """递归搜响应里第一个 http URL(key ∈ keys)。accept(key, url) 可再过滤(如
    seedance 只认 video_url 或视频扩展名);None = 任意 http 值都收。找不到返 ""。"""
    def _walk(o: Any) -> Optional[str]:
        if isinstance(o, dict):
            for k, v in o.items():
                if k in keys and isinstance(v, str) and v.startswith("http"):
                    if accept is None or accept(k, v):
                        return v
                r = _walk(v)
                if r:
                    return r
        elif isinstance(o, list):
            for x in o:
                r = _walk(x)
                if r:
                    return r
        return None
    return _walk(obj) or ""
