"""定时任务守护循环 + 单 job 执行引擎(§8.5;从 app.py lifespan 析出,2026-07-23)。

职责边界:core/scheduler.py 是服务层(job CRUD / claim / next_run 计算 / 投递),
本模块是 web 侧执行引擎 —— 认领到点 job → 解析目标 task → 抢 run 锁 →
复用 run_agent_bg 起 run → 超时协作 cancel → 确定性兜底投递 + 回写 last_*。
住 web/ 不下沉 core:执行依赖 broker / runs / app.state(inflight/draining),
方向同 runs.py。

仿 _disk_scanner 的 plain-asyncio 范式,不引 APScheduler/Celery。tick 间隔只决定
最坏延迟(≤1 tick),不决定会不会漏(claim 取 next_run<=now 的全部)。
ZCBOT_DISABLE_SCHEDULER=1 整体关掉(对照 Claude Code CLAUDE_CODE_DISABLE_CRON)。
"""
from __future__ import annotations

import asyncio
import os
from typing import Optional
from uuid import UUID, uuid4

from sqlalchemy import select, update

from core.paths import from_db_path, to_db_path
from core.storage import session_scope
from core.storage.models import ScheduledJob, Task

from .broker import broker
from .model_gate import resolve_model_profile
from .run_lifecycle import (
    RunScheduleError,
    RunTaskBusy,
    RunTaskNotFound,
    claim_run_with_message,
    schedule_claimed_run,
)


def start_scheduler(app, cfg: dict) -> Optional[asyncio.Task]:
    """起定时任务守护循环;ZCBOT_DISABLE_SCHEDULER 在则返 None。"""
    enabled = os.getenv("ZCBOT_DISABLE_SCHEDULER", "").strip() not in ("1", "true", "yes")
    if not enabled:
        return None
    sched_tick = int(os.getenv("ZCBOT_SCHEDULER_TICK_SECONDS", "10") or "10")
    sched_sema = asyncio.Semaphore(int(os.getenv("ZCBOT_SCHEDULER_CONCURRENCY", "4") or "4"))

    async def _execute_scheduled_job(snap: dict) -> None:
        """认领后跑一个 job:解析目标 task → 抢 run 锁 → run_agent_bg → 投递 + 记账。"""
        from core.agent_builder import (
            resolve_workspace, working_dir_from_name, validate_task_name, InvalidTaskName,
        )
        from core.scheduler import build_run_message, deliver_notify, record_result
        from core.storage.utils import ensure_local_task_row

        job_id = snap["job_id"]
        uid = snap["user_id"]
        async with sched_sema:
            try:
                profile, model_id = resolve_model_profile(snap.get("model_profile") or "")
                ws = resolve_workspace(None, cfg)
                # 目标 task:persistent 用绑定 task(缺则新建并回填);isolated 用稳定 per-job 目录
                tid: Optional[UUID] = None
                if snap["mode"] == "persistent" and snap.get("bound_task_id"):
                    tid = snap["bound_task_id"]
                    # 绑定 task 可能已被删(SET NULL 已处理 None;这里再查实在性)
                    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:
                        tid = None
                if tid is None:
                    tid = uuid4()
                    wd_name = f"scheduled-{str(job_id)[:8]}"
                    fs_dir = working_dir_from_name(ws, uid, wd_name)
                    fs_dir.mkdir(parents=True, exist_ok=True)
                    disp = f"{snap['name']}"
                    try:
                        disp = validate_task_name(disp)
                    except InvalidTaskName:
                        disp = wd_name  # 名字含非法字符 → 退到安全名
                    ensure_local_task_row(
                        task_id=tid, name=disp, working_dir=to_db_path(fs_dir),
                        skill=snap.get("skill") or "", user_id=uid,
                        model=model_id, model_profile=profile,
                        description="(定时任务自动创建)",
                        scheduled_job_id=job_id,
                        title_source="fixed",
                    )
                    if snap["mode"] == "persistent":
                        with session_scope() as s:
                            s.execute(update(ScheduledJob).where(
                                ScheduledJob.job_id == job_id
                            ).values(bound_task_id=tid))

                message = build_run_message(snap)
                try:
                    claim_run_with_message(tid, uid, message)
                except RunTaskNotFound:
                    record_result(
                        job_id, status="error", task_id=tid,
                        error="目标 task 不存在",
                    )
                    return
                except RunTaskBusy:
                    record_result(
                        job_id, status="skipped", task_id=tid,
                        error="目标 task 正忙,本次跳过",
                    )
                    print(f"[scheduler] job {str(job_id)[:8]} skipped (task busy)")
                    return
                try:
                    runner = schedule_claimed_run(
                        app, tid, uid, message, scheduled=True,
                    )
                except RunScheduleError as e:
                    record_result(
                        job_id, status="error", task_id=tid, error=str(e),
                    )
                    print(
                        f"[scheduler] job {str(job_id)[:8]} scheduling failed: {e}"
                    )
                    return

                timeout = int(snap.get("timeout_seconds") or 0)
                timed_out = False
                if timeout > 0:
                    done, _pending = await asyncio.wait({runner}, timeout=timeout)
                    if not done:
                        timed_out = True
                        broker.request_cancel(tid)  # 协作式停;loop 在 chunk 间 poll 到即退
                        print(f"[scheduler] job {str(job_id)[:8]} timed out ({timeout}s), cancelling")
                        await runner
                else:
                    await runner

                # 超时被掐断:run_agent_bg 把 task 标 cancelled，但只有这里知道它是
                # scheduler timeout 而非用户停止。job 必须另记 error
                # —— 否则会误判成 ok(掩盖"跑到一半没推送"),且不计入连续失败/不触发
                # 兜底。半成品不投递 notify,直接收尾返回。
                if timed_out:
                    record_result(job_id, status="error", task_id=tid,
                                  error=f"运行超过超时上限 {timeout}s 未完成,已中断(本次未推送)")
                    print(f"[scheduler] job {str(job_id)[:8]} recorded as timeout-error")
                    return

                # run 终态:run_agent_bg 收尾把 run_status 写回 idle(ok)/error
                with session_scope() as s:
                    st = s.execute(
                        select(Task.run_status, Task.run_error).where(Task.task_id == tid)
                    ).first()
                if st is not None and st.run_status == "error":
                    record_result(job_id, status="error", task_id=tid, error=st.run_error)
                    print(f"[scheduler] job {str(job_id)[:8]} run error: {st.run_error}")
                    return

                # 第 3 层确定性兜底投递(notify);失败不影响 run 已成功这一事实
                if snap.get("notify"):
                    try:
                        with session_scope() as s:
                            wd_db = s.execute(
                                select(Task.working_dir).where(Task.task_id == tid)
                            ).scalar_one_or_none()
                        fs_dir = from_db_path(wd_db) if wd_db else ws
                        await asyncio.get_running_loop().run_in_executor(
                            None, lambda: deliver_notify(
                                snap["notify"], job_name=snap["name"],
                                working_dir=fs_dir, tz=snap["tz"],
                                user_id=snap["user_id"],
                            )
                        )
                    except Exception as e:
                        print(f"[scheduler] job {str(job_id)[:8]} notify failed: {type(e).__name__}: {e}")
                record_result(job_id, status="ok", task_id=tid)
                print(f"[scheduler] job {str(job_id)[:8]} '{snap['name']}' done")
            except Exception as e:
                print(f"[scheduler] job {str(job_id)[:8]} crashed: {type(e).__name__}: {e}")
                try:
                    record_result(job_id, status="error", task_id=None, error=f"{type(e).__name__}: {e}")
                except Exception:
                    pass

    async def _scheduler_loop() -> None:
        from core.scheduler import claim_due_jobs
        loop = asyncio.get_running_loop()
        while True:
            try:
                await asyncio.sleep(sched_tick)
                if getattr(app.state, "draining", None) is not None and app.state.draining.is_set():
                    continue  # 关停 drain 期不起新 job
                due = await loop.run_in_executor(None, claim_due_jobs)
                for snap in due:
                    asyncio.create_task(_execute_scheduled_job(snap))
                if due:
                    print(f"[scheduler] fired {len(due)} job(s)")
            except asyncio.CancelledError:
                raise
            except Exception as e:
                print(f"[scheduler] loop error: {type(e).__name__}: {e}")

    task = asyncio.create_task(_scheduler_loop(), name="scheduler")
    print(f"[scheduler] enabled (tick={sched_tick}s)")
    return task
