"""微信渠道服务层(DESIGN §8.7):绑定 CRUD + 主动推送 + `send_to_user` 渠道抽象。

- 绑定行的 `bot_token` / `latest_context_token` 经 `crypto` 加解密;快照(BindingSnapshot)
  脱离 session、含明文 token,**仅 host 进程内用,绝不外泄/进沙箱**。
- 主动推送 24h 窗口:`context_token` 仅在末次入站 ~24h 内可用;超期/未开口 → 推不出,
  返回 reason 给调用方退邮件兜底(§8.5)。
- `send_to_user` 是渠道抽象:scheduler / WechatPushTool 调它,不感知 ClawBot/企业微信;
  企业微信(渠道 B)后续在此追加一路。

阻塞 IO(DB + httpx),调用方放 to_thread / executor。
"""
from __future__ import annotations

import os
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import UUID

from sqlalchemy import func, select, update

from core.storage import session_scope
from core.storage.models import ChannelBinding, Message, Task
from core.wechat import crypto
from core.wechat.ilink import DEFAULT_BASE, ILinkClient

CONTEXT_TOKEN_TTL = timedelta(hours=24)
_CLAWBOT = "clawbot"
_WECOM = "wecom"


def _get_or_new(s, user_id: UUID, channel: str) -> ChannelBinding:
    row = s.get(ChannelBinding, (user_id, channel))
    if row is None:
        row = ChannelBinding(user_id=user_id, channel=channel, config={})
        s.add(row)
    return row


def clawbot_enabled() -> bool:
    """ClawBot 渠道总开关(沿用「有开关才挂」范式,§3.4)。"""
    return os.getenv("ZCBOT_WECHAT_BOT_ENABLED", "").strip().lower() in (
        "1", "true", "yes", "on",
    )


# ─────────────────────────── 绑定快照 / CRUD ───────────────────────────

@dataclass
class BindingSnapshot:
    user_id: UUID
    bot_token: str                       # 明文(已解密)
    base_url: str
    user_im_id: Optional[str]
    context_token: Optional[str]         # 明文(已解密)
    context_token_at: Optional[datetime]
    chat_task_id: Optional[UUID]
    status: str


def _snap(row: ChannelBinding) -> BindingSnapshot:
    """channel='clawbot' 行 → 快照(解密 token,反序列化 config)。"""
    cfg = row.config or {}
    cta = cfg.get("context_token_at")
    cti = cfg.get("chat_task_id")
    return BindingSnapshot(
        user_id=row.user_id,
        bot_token=crypto.dec(cfg.get("bot_token")) or "",
        base_url=cfg.get("base_url") or DEFAULT_BASE,
        user_im_id=cfg.get("user_im_id"),
        context_token=crypto.dec(cfg.get("latest_context_token")),
        context_token_at=datetime.fromisoformat(cta) if cta else None,
        chat_task_id=UUID(cti) if cti else None,
        status=row.status,
    )


def get_binding(user_id: UUID) -> Optional[BindingSnapshot]:
    with session_scope() as s:
        row = s.get(ChannelBinding, (user_id, _CLAWBOT))
        return _snap(row) if row else None


def list_active_bindings() -> list[BindingSnapshot]:
    """入站长轮询管理器用:所有 active 的 ClawBot 绑定(含明文 bot_token)。"""
    with session_scope() as s:
        rows = (
            s.execute(
                select(ChannelBinding).where(
                    ChannelBinding.channel == _CLAWBOT,
                    ChannelBinding.status == "active",
                )
            )
            .scalars()
            .all()
        )
        return [_snap(r) for r in rows]


def upsert_clawbot_binding(
    user_id: UUID, bot_token: str, base_url: str, *, bot_im_id: Optional[str] = None
) -> None:
    """扫码 confirmed 后写/更新绑定。bot_token 加密存进 config(保留已有 user_im_id 等)。"""
    now = datetime.now(timezone.utc)
    with session_scope() as s:
        row = _get_or_new(s, user_id, _CLAWBOT)
        cfg = dict(row.config or {})
        cfg["bot_token"] = crypto.enc(bot_token)
        cfg["base_url"] = base_url or DEFAULT_BASE
        if bot_im_id:
            cfg["bot_im_id"] = bot_im_id
        row.config = cfg                      # 重新赋值 → ORM 追踪 JSONB 变更
        row.status = "active"
        row.updated_at = now


def refresh_context_token(user_id: UUID, user_im_id: str, context_token: str) -> None:
    """每条入站消息刷新该用户的 context_token(+时间戳)——主动推送窗口靠它续命。"""
    now = datetime.now(timezone.utc)
    with session_scope() as s:
        row = s.get(ChannelBinding, (user_id, _CLAWBOT))
        if row is None:
            return
        cfg = dict(row.config or {})
        if user_im_id:
            cfg["user_im_id"] = user_im_id
        cfg["latest_context_token"] = crypto.enc(context_token)
        cfg["context_token_at"] = now.isoformat()
        row.config = cfg
        row.updated_at = now


def set_chat_task(user_id: UUID, task_id: UUID) -> None:
    now = datetime.now(timezone.utc)
    with session_scope() as s:
        row = s.get(ChannelBinding, (user_id, _CLAWBOT))
        if row is not None:
            cfg = dict(row.config or {})
            cfg["chat_task_id"] = str(task_id)
            row.config = cfg
            row.updated_at = now


def unbind(user_id: UUID) -> bool:
    """解绑 ClawBot(标 revoked,不物理删 → 保留轨迹)。返回是否有绑定被改。"""
    now = datetime.now(timezone.utc)
    with session_scope() as s:
        row = s.get(ChannelBinding, (user_id, _CLAWBOT))
        if row is None:
            return False
        row.status = "revoked"
        row.updated_at = now
        return True


# ─────────────────────────── 推送 ───────────────────────────

@dataclass
class PushResult:
    ok: bool
    channel: str = "clawbot"
    # sent | no_binding | never_opened | token_stale | error:<...>
    reason: str = ""


def _token_fresh(snap: BindingSnapshot) -> bool:
    if not snap.context_token or snap.context_token_at is None:
        return False
    at = snap.context_token_at
    if at.tzinfo is None:
        at = at.replace(tzinfo=timezone.utc)
    return (datetime.now(timezone.utc) - at) < CONTEXT_TOKEN_TTL


def push_clawbot(
    user_id: UUID, text: str = "", file_path: Optional[str] = None
) -> PushResult:
    """主动推一条到用户个人微信。仅在 24h 窗口内可用,否则返回 reason 供兜底。"""
    snap = get_binding(user_id)
    if snap is None or snap.status != "active":
        return PushResult(False, reason="no_binding")
    if not snap.user_im_id or not snap.context_token:
        return PushResult(False, reason="never_opened")  # 冷启动:用户从未开口
    if not _token_fresh(snap):
        return PushResult(False, reason="token_stale")    # 超 24h 未互动
    client = ILinkClient(snap.bot_token, snap.base_url)
    try:
        if text:
            client.send_text(snap.user_im_id, snap.context_token, text)
        if file_path:
            client.send_file(snap.user_im_id, snap.context_token, file_path)
    except Exception as e:  # noqa: BLE001 —— 调用方据 reason 决定兜底
        return PushResult(False, reason=f"error: {str(e)[:200]}")
    return PushResult(True, reason="sent")


# ─────────────── 企业微信(渠道 B,纯推送;无 24h 窗口约束)───────────────

def get_wecom_userid(user_id: UUID) -> Optional[str]:
    with session_scope() as s:
        row = s.get(ChannelBinding, (user_id, _WECOM))
        if row is None or row.status != "active":
            return None
        return (row.config or {}).get("wecom_userid")


def get_user_by_wecom_userid(wecom_userid: str) -> Optional[UUID]:
    """企业微信回调只带 wecom_userid → 反查内部 user_id(仅 active 绑定)。入站对话用。"""
    if not wecom_userid:
        return None
    with session_scope() as s:
        row = s.execute(
            select(ChannelBinding.user_id).where(
                ChannelBinding.channel == _WECOM,
                ChannelBinding.status == "active",
                ChannelBinding.config["wecom_userid"].astext == wecom_userid,
            )
        ).first()
        return row[0] if row else None


def upsert_wecom_binding(user_id: UUID, wecom_userid: str) -> None:
    """OAuth 拿到 userid 后写/更新绑定。合并进 config(保留 chat_task_id 等已有字段)。"""
    now = datetime.now(timezone.utc)
    with session_scope() as s:
        row = _get_or_new(s, user_id, _WECOM)
        cfg = dict(row.config or {})
        cfg["wecom_userid"] = wecom_userid
        row.config = cfg
        row.status = "active"
        row.updated_at = now


def get_wecom_chat_task(user_id: UUID) -> Optional[UUID]:
    """企业微信入站对话常驻 task id(无 → None)。"""
    with session_scope() as s:
        row = s.get(ChannelBinding, (user_id, _WECOM))
        if row is None:
            return None
        cti = (row.config or {}).get("chat_task_id")
        return UUID(cti) if cti else None


def set_wecom_chat_task(user_id: UUID, task_id: UUID) -> None:
    now = datetime.now(timezone.utc)
    with session_scope() as s:
        row = s.get(ChannelBinding, (user_id, _WECOM))
        if row is not None:
            cfg = dict(row.config or {})
            cfg["chat_task_id"] = str(task_id)
            row.config = cfg
            row.updated_at = now


def unbind_wecom(user_id: UUID) -> bool:
    now = datetime.now(timezone.utc)
    with session_scope() as s:
        row = s.get(ChannelBinding, (user_id, _WECOM))
        if row is None:
            return False
        row.status = "revoked"
        row.updated_at = now
        return True


def push_wecom(user_id: UUID, text: str = "", file_path: Optional[str] = None) -> PushResult:
    """企业微信主动推一条(无条件,不挑活跃度)。"""
    from core.wechat import wecom
    wuid = get_wecom_userid(user_id)
    if not wuid:
        return PushResult(False, channel="wecom", reason="no_binding")
    try:
        if text:
            wecom.send_text(wuid, text)
        if file_path:
            wecom.send_file(wuid, file_path)
    except Exception as e:  # noqa: BLE001 —— 透出 errcode/errmsg 便于排错
        return PushResult(False, channel="wecom", reason=f"error: {str(e)[:200]}")
    return PushResult(True, channel="wecom", reason="sent")


@dataclass
class DeliveryReport:
    results: list[PushResult] = field(default_factory=list)

    @property
    def delivered(self) -> bool:
        return any(r.ok for r in self.results)


def active_channels() -> list[str]:
    """部署级「哪些渠道开了」的**唯一真相源**:门槛判断(`wechat_push_available`)
    与投递(`send_to_user`)都引它,避免两处各列各的(曾漏判企业微信致工具不挂)。
    加渠道只改这一处,门槛与投递自动一致。顺序即投递优先序。"""
    from core.wechat.wecom import wecom_configured
    chans: list[str] = []
    if clawbot_enabled():
        chans.append(_CLAWBOT)
    if wecom_configured():
        chans.append(_WECOM)
    return chans


_DISPATCH = {_CLAWBOT: push_clawbot, _WECOM: push_wecom}


def ensure_channel_chat_task(uid: UUID, channel: str) -> Optional[UUID]:
    """确保 uid 的 channel 常驻 chat task 存在(未软删),返回 task_id;不存在则新建并回填绑定。

    channel ∈ {'wechat','wecom'}。wechat 无 binding → 返回 None(没法建/记)。
    入站对话(`_run_channel_conversation`)与 push 记录(`send_to_user`)共用此入口,
    避免两条"解析/建 chat task"路径逻辑漂移。建 task 逻辑搬自原 _run_channel_conversation。
    """
    from uuid import uuid4

    from core.agent_builder import (  # 延迟 import:service 被 tools.wechat_bot 引用,
        load_config, resolve_workspace, working_dir_from_name,  # agent_builder 又 import tools.wechat_bot
    )  # → 顶层 import 循环;函数内 import 打破(同 scheduler.py:227 范式)
    from core.capabilities import ModelCapabilities
    from core.paths import ROOT, to_db_path
    from core.storage.models import Task
    from core.storage.utils import ensure_local_task_row

    if channel == "wecom":
        existing_tid = get_wecom_chat_task(uid)
        task_name, slug, desc = "企业微信对话", f"wecom-{str(uid)[:8]}", "(企业微信对话)"
        set_task = set_wecom_chat_task
    else:  # wechat
        snap = get_binding(uid)
        if snap is None:
            return None
        existing_tid = snap.chat_task_id
        task_name, slug, desc = "微信对话", f"wechat-{str(uid)[:8]}", "(微信 ClawBot 对话)"
        set_task = set_chat_task

    tid = existing_tid
    need_create = tid is None
    if not need_create:
        with session_scope() as s:
            exists = s.execute(
                select(Task.task_id).where(Task.task_id == tid, Task.deleted_at.is_(None))
            ).first()
        if exists is None:
            need_create = True
    if need_create:
        cfg = load_config()
        profile = cfg["default_model"]
        caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"])
        ws = resolve_workspace(None, cfg)
        tid = uuid4()
        fs_dir = working_dir_from_name(ws, uid, slug)
        fs_dir.mkdir(parents=True, exist_ok=True)
        ensure_local_task_row(
            task_id=tid, name=task_name, working_dir=to_db_path(fs_dir),
            skill="", user_id=uid, model=caps.model_id, model_profile=profile,
            description=desc, channel=channel,
        )
        set_task(uid, tid)
    return tid


# ─────────────────────── channel 长会话上下文软重置(0019) ───────────────────────

# gap 默认值:超过它未说话 → 入站时软重置(保留上一轮原文做续聊锚点)。可被
# config.json 的 channel.session_gap_hours 覆盖(见 reload 入口)。
SESSION_GAP_HOURS_DEFAULT = 6.0

# 用户在 channel 里发这些词 → 手动「新话题」硬重置(base 推到总数,彻底从零)。
NEW_TOPIC_COMMANDS = frozenset({"新话题", "新会话", "/new", "清空上下文"})


def reset_channel_context(task_id: UUID, *, hard: bool) -> int:
    """推进 task 的 context_base_idx(软重置),返回新 base。不删任何消息。

    hard=True(手动「新话题」):base = 总消息数 → 下一条入站起彻底新会话;
                          顺带清 context_summary(0021)—— 彻底从零,旧摘要不再注入。
    hard=False(自动 gap):base = 最后一条 user 消息 idx → 新窗口仍带上「上一轮」原文,
                          续聊接得上;无 user 消息(理论上不会)退化为总数。
                          摘要保留(与"留上一轮做续聊锚点"同语义)。
    """
    with session_scope() as s:
        total = s.execute(
            select(func.count()).select_from(Message).where(Message.task_id == task_id)
        ).scalar_one()
        if hard:
            new_base = int(total)
        else:
            last_user_idx = s.execute(
                select(func.max(Message.idx)).where(
                    Message.task_id == task_id,
                    Message.payload["role"].astext == "user",
                )
            ).scalar_one_or_none()
            new_base = int(last_user_idx) if last_user_idx is not None else int(total)
        vals: dict = {"context_base_idx": new_base}
        if hard:
            vals["context_summary"] = None
        s.execute(update(Task).where(Task.task_id == task_id).values(**vals))
    return new_base


def maybe_gap_reset(task_id: UUID, gap_hours: float = SESSION_GAP_HOURS_DEFAULT) -> bool:
    """入站时检测:距上次消息超过 gap_hours → 软重置(保留上一轮)。返回是否重置。

    仅入站对话调用(push 记录不触发)。gap_hours <= 0 视为关闭自动分段。
    """
    if gap_hours <= 0:
        return False
    with session_scope() as s:
        last_at = s.execute(
            select(func.max(Message.created_at)).where(Message.task_id == task_id)
        ).scalar_one_or_none()
    if last_at is None:
        return False  # 空 task,首条入站,无需重置
    if (datetime.now(timezone.utc) - last_at) <= timedelta(hours=gap_hours):
        return False
    reset_channel_context(task_id, hard=False)
    return True


def _file_rel_to_user_root(user_id: UUID, file_path: str) -> Optional[str]:
    """宿主绝对路径 → user_root 相对 POSIX(如 scheduled-<jobid>/x.md)。
    文件不在 user_root 内(外部 --working-dir)→ None。"""
    from pathlib import Path

    from core.agent_builder import load_config, resolve_workspace, user_root
    try:
        ws = resolve_workspace(None, load_config())
        root = user_root(ws, user_id)
        return Path(file_path).resolve().relative_to(root.resolve()).as_posix()
    except Exception:
        return None


def _build_push_message(text: str, rel: Optional[str]) -> str:
    """构造写进 chat task 的 assistant 消息:推送摘要 + 可点文件链接 + agent read 路径。

    此消息**只进 DB 供 web 端渲染**(企业微信端另走 push_wecom 的 send_text 摘要 +
    send_file 附件,不经此链接)。故文件链接用 **user_root 相对路径**(如
    `scheduled-<jobid>/x.txt`)——与 chip / 正文相对链接同一契约,web 的正文链接拦截器
    默认就按 user_root 相对解析、复用文件预览 modal。**不要**再写成完整下载 API
    `[名](/v1/files/download?path=<rel>)`:那把具体 API 端点这个渲染细节焊进数据层,且
    拦截器会把整串 URL 当相对 rel 二次包裹 → 404(踩过 task 7f904e5f;历史消息由前端
    识别该形式兜底,新消息走这里的相对路径)。"""
    lines: list[str] = []
    if text and text.strip():
        lines.append(text.strip())
    if rel:
        fname = rel.rsplit("/", 1)[-1]
        lines.append(f"产物文件:[{fname}]({rel})")
        lines.append(f"(如需基于此文件提问,可读取 ../{rel})")
    return "\n\n".join(lines)


def _record_push_to_chat(
    report: DeliveryReport, user_id: UUID, text: str,
    file_path: Optional[str], source_task_id: Optional[UUID],
) -> None:
    """把投递成功的推送记为对应渠道 chat task 的 assistant 消息(web 端可见 +
    agent 可基于追问)。Unified 模式:进 agent 上下文(推送是 bot 发给用户的话,
    记得自己发过什么 = 连贯,非污染)。记录失败不影响投递(吞掉打日志)。"""
    if not report.delivered:
        return
    from core.storage.utils import append_channel_message

    rel = _file_rel_to_user_root(user_id, file_path) if file_path else None
    for r in report.results:
        if not r.ok:
            continue
        ch = "wechat" if r.channel == _CLAWBOT else r.channel  # clawbot→wechat(建 task channel)
        try:
            tid = ensure_channel_chat_task(user_id, ch)
            if tid is None:
                continue
            if source_task_id is not None and tid == source_task_id:
                continue  # 调用方即该 chat task 自己的 run,tool 记录已在,不重复插摘要
            append_channel_message(tid, _build_push_message(text, rel), kind="push")
        except Exception as e:  # noqa: BLE001 —— 记录失败不放大,投递已成功
            print(f"[push] record to {ch} chat task failed: {type(e).__name__}: {e}")


def send_to_user(
    user_id: UUID,
    text: str = "",
    file_path: Optional[str] = None,
    channel: Optional[str] = None,
    *,
    source_task_id: Optional[UUID] = None,
) -> DeliveryReport:
    """渠道抽象:按 `active_channels()` 列出的已开渠道投递 + 把推送记进渠道 chat task。

    - `channel=None`(默认):广播到所有已开渠道(定时任务/不点名推送沿用此口径)。
    - `channel="wecom"|"clawbot"`:用户点名某个微信时只投这一条;若该渠道未开/无效,
      返回单条 `no_binding` 结果(不静默回退到别的渠道,避免又推到没点名的渠道)。
    - 投递成功后,对每个成功渠道把推送(摘要 + 文件链接 + read 路径)作为 assistant
      消息写进该渠道 chat task(不存在自动建)。`source_task_id` = 调用方所在 task:
      若恰为目标 chat task 自己(如用户在微信里让 agent 推),tool 记录已在,跳过去重。
    """
    report = DeliveryReport()
    if channel is not None:
        if channel in active_channels():
            report.results.append(_DISPATCH[channel](user_id, text, file_path))
        else:
            report.results.append(PushResult(False, channel=channel, reason="no_binding"))
    else:
        for ch in active_channels():
            report.results.append(_DISPATCH[ch](user_id, text, file_path))
    _record_push_to_chat(report, user_id, text, file_path, source_task_id)
    return report
