# 中文注释：
# 文件：echobot/cron/service.py
# 说明：定时任务服务与调度数据结构。

"""Cron service for scheduling agent tasks."""

import asyncio
import json
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Coroutine

from loguru import logger

from echobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule, CronStore


def _now_ms() -> int:
    return int(time.time() * 1000)


def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
    """Compute next run time in ms."""
    if schedule.kind == "at":
        return schedule.at_ms if schedule.at_ms and schedule.at_ms > now_ms else None
    
    if schedule.kind == "every":
        if not schedule.every_ms or schedule.every_ms <= 0:
            return None
        # Next interval from now
        return now_ms + schedule.every_ms
    
    if schedule.kind == "cron" and schedule.expr:
        try:
            from croniter import croniter

            tzinfo = None
            if schedule.tz:
                from zoneinfo import ZoneInfo

                tzinfo = ZoneInfo(schedule.tz)

            # Use provided `now_ms` as the cron base time so behavior is stable
            # and aligned with the scheduler's current timeline.
            base_time = datetime.fromtimestamp(now_ms / 1000, tz=tzinfo)
            cron = croniter(schedule.expr, base_time)
            next_dt = cron.get_next(datetime)
            return int(next_dt.timestamp() * 1000)
        except Exception:
            return None
    
    return None


class CronService:
    """
    定时任务调度服务。

    负责管理和执行预定的定时任务，支持以下功能：
    - 创建、删除、启用/禁用定时任务
    - 支持多种调度模式：一次性(at)、周期性(every)、Cron表达式
    - 自动执行到期的任务
    - 持久化任务状态到磁盘

    线程安全：
        - 使用 asyncio.Lock 保护文件写入操作，防止并发写入导致文件描述符泄漏
        - 所有修改状态的操作都是异步的

    错误处理：
        - 文件读写失败会记录日志但不会中断服务运行
        - 任务执行失败会记录错误信息到任务状态中
    """

    def __init__(
        self,
        store_path: Path,
        on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None
    ):
        """
        初始化定时任务服务。

        Args:
            store_path: 任务存储文件路径 (JSON格式)
            on_job: 任务执行回调函数，接收 CronJob，返回执行结果的文本
        """
        self.store_path = store_path
        self.on_job = on_job  # 任务执行回调函数
        self._store: CronStore | None = None  # 内存中的任务数据缓存
        self._timer_task: asyncio.Task | None = None  # 定时器任务
        self._running: bool = False  # 服务运行状态
        self._save_lock = asyncio.Lock()  # 文件写入锁，防止并发写入
    
    def _load_store(self) -> CronStore:
        """
        从磁盘加载任务数据。

        优先使用内存缓存(_store)，避免频繁读取磁盘。
        如果缓存不存在，则从 JSON 文件加载。

        Returns:
            CronStore: 任务数据存储对象

        Note:
            这是一个同步方法，因为只读取文件一次后会缓存在内存中
        """
        # 如果已有缓存，直接返回
        if self._store:
            return self._store

        # 从磁盘文件加载数据
            try:
                data = json.loads(self.store_path.read_text())
                jobs = []
                for j in data.get("jobs", []):
                    jobs.append(CronJob(
                        id=j["id"],
                        name=j["name"],
                        enabled=j.get("enabled", True),
                        schedule=CronSchedule(
                            kind=j["schedule"]["kind"],
                            at_ms=j["schedule"].get("atMs"),
                            every_ms=j["schedule"].get("everyMs"),
                            expr=j["schedule"].get("expr"),
                            tz=j["schedule"].get("tz"),
                        ),
                        payload=CronPayload(
                            kind=j["payload"].get("kind", "agent_turn"),
                            message=j["payload"].get("message", ""),
                            deliver=j["payload"].get("deliver", False),
                            channel=j["payload"].get("channel"),
                            to=j["payload"].get("to"),
                        ),
                        state=CronJobState(
                            next_run_at_ms=j.get("state", {}).get("nextRunAtMs"),
                            last_run_at_ms=j.get("state", {}).get("lastRunAtMs"),
                            last_status=j.get("state", {}).get("lastStatus"),
                            last_error=j.get("state", {}).get("lastError"),
                        ),
                        created_at_ms=j.get("createdAtMs", 0),
                        updated_at_ms=j.get("updatedAtMs", 0),
                        delete_after_run=j.get("deleteAfterRun", False),
                    ))
                self._store = CronStore(jobs=jobs)
            except Exception as e:
                logger.warning(f"Failed to load cron store: {e}")
                self._store = CronStore()
        else:
            self._store = CronStore()
        
        return self._store
    
    async def _save_store(self) -> None:
        """
        将任务调度数据保存到磁盘文件。

        使用异步锁防止并发写入导致文件描述符泄漏。
        使用传统的 open() 上下文管理器确保文件正确关闭。

        异常处理：
            - 如果 store 为空，直接返回
            - 如果目录不存在，自动创建
            - 写入失败会记录错误日志
        """
        # 如果没有数据，直接返回
        if not self._store:
            return

        # 使用异步锁保护文件写入操作，防止多个协程同时写入
        # 这可以避免文件描述符竞争导致的 "Too many open files" 错误
        async with self._save_lock:
            # 确保父目录存在
            self.store_path.parent.mkdir(parents=True, exist_ok=True)

            # 构建要序列化的数据
            data = {
                "version": self._store.version,
                "jobs": [
                    {
                        "id": j.id,
                        "name": j.name,
                        "enabled": j.enabled,
                        "schedule": {
                            "kind": j.schedule.kind,
                            "atMs": j.schedule.at_ms,
                            "everyMs": j.schedule.every_ms,
                            "expr": j.schedule.expr,
                            "tz": j.schedule.tz,
                        },
                        "payload": {
                            "kind": j.payload.kind,
                            "message": j.payload.message,
                            "deliver": j.payload.deliver,
                            "channel": j.payload.channel,
                            "to": j.payload.to,
                        },
                        "state": {
                            "nextRunAtMs": j.state.next_run_at_ms,
                            "lastRunAtMs": j.state.last_run_at_ms,
                            "lastStatus": j.state.last_status,
                            "lastError": j.state.last_error,
                        },
                        "createdAtMs": j.created_at_ms,
                        "updatedAtMs": j.updated_at_ms,
                        "deleteAfterRun": j.delete_after_run,
                    }
                    for j in self._store.jobs
                ]
            }

            # 使用传统的 open() 上下文管理器写入文件
            # 相比 pathlib.write_text，这种方式更可控，能确保文件句柄被正确关闭
            json_str = json.dumps(data, indent=2)
            try:
                with open(self.store_path, 'w', encoding='utf-8') as f:
                    f.write(json_str)
            except OSError as e:
                # 捕获文件写入错误，记录日志
                logger.error(f"Failed to save cron store: {e}")
    
    async def start(self) -> None:
        """
        启动定时任务服务。

        初始化流程：
            1. 设置运行状态为 True
            2. 从磁盘加载任务数据
            3. 重新计算所有任务的下次执行时间
            4. 保存状态到磁盘（确保 nextRunAtMs 等字段正确）
            5. 启动定时器等待任务到期

        Note:
            这是异步方法，需要在 asyncio 事件循环中调用
        """
        self._running = True
        self._load_store()
        self._recompute_next_runs()
        await self._save_store()
        self._arm_timer()
        logger.info(f"Cron service started with {len(self._store.jobs if self._store else [])} jobs")

    def stop(self) -> None:
        """
        停止定时任务服务。

        会取消正在等待的定时器任务，但正在执行的任务不会被中断。
        """
        self._running = False
        if self._timer_task:
            self._timer_task.cancel()
            self._timer_task = None

    def _recompute_next_runs(self) -> None:
        """重新计算所有启用任务的下次执行时间。"""
        if not self._store:
            return
        now = _now_ms()
        for job in self._store.jobs:
            if job.enabled:
                job.state.next_run_at_ms = _compute_next_run(job.schedule, now)
    
    def _get_next_wake_ms(self) -> int | None:
        """Get the earliest next run time across all jobs."""
        if not self._store:
            return None
        times = [j.state.next_run_at_ms for j in self._store.jobs 
                 if j.enabled and j.state.next_run_at_ms]
        return min(times) if times else None
    
    def _arm_timer(self) -> None:
        """
        设置下一次定时器触发。

        查找最近需要执行的任务，计算延迟时间，创建一个异步任务等待执行。
        如果没有待执行的任务或服务已停止，则不设置定时器。

        Implementation:
            - 取消之前的定时器任务（避免重复）
            - 查找最近的下次执行时间
            - 创建异步任务在指定延迟后执行
        """
        if self._timer_task:
            self._timer_task.cancel()

        next_wake = self._get_next_wake_ms()
        if not next_wake or not self._running:
            return

        delay_ms = max(0, next_wake - _now_ms())
        delay_s = delay_ms / 1000

        async def tick():
            """定时器回调函数，等待指定时间后检查到期任务"""
            await asyncio.sleep(delay_s)
            if self._running:
                await self._on_timer()

        self._timer_task = asyncio.create_task(tick())

    async def _on_timer(self) -> None:
        """
        定时器触发回调。

        检查所有任务，找出已到期的任务并执行。
        执行完成后保存状态并重新设置下一次定时器。

        Workflow:
            1. 查找所有已到期的任务（enabled=True 且 next_run_at_ms <= now）
            2. 依次执行每个到期任务
            3. 保存任务状态到磁盘
            4. 重新计算并设置下一次定时器
        """
        if not self._store:
            return

        now = _now_ms()
        # 找出所有到期且已启用的任务
        due_jobs = [
            j for j in self._store.jobs
            if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
        ]

        # 执行所有到期任务
        for job in due_jobs:
            await self._execute_job(job)

        # 执行完成后保存状态
        await self._save_store()
        # 重新设置下一次定时器
        self._arm_timer()

    async def _execute_job(self, job: CronJob) -> None:
        """
        执行单个定时任务。

        Args:
            job: 要执行的任务对象

        Execution Flow:
            1. 记录任务开始时间
            2. 调用 on_job 回调执行实际任务
            3. 更新任务状态（成功/失败）
            4. 对于一次性任务，处理删除或禁用
            5. 对于循环任务，计算下次执行时间
        """
        start_ms = _now_ms()
        logger.info(f"Cron: executing job '{job.name}' ({job.id})")
        
        try:
            response = None
            if self.on_job:
                response = await self.on_job(job)
            
            job.state.last_status = "ok"
            job.state.last_error = None
            logger.info(f"Cron: job '{job.name}' completed")
            
        except Exception as e:
            job.state.last_status = "error"
            job.state.last_error = str(e)
            logger.error(f"Cron: job '{job.name}' failed: {e}")
        
        job.state.last_run_at_ms = start_ms
        job.updated_at_ms = _now_ms()
        
        # Handle one-shot jobs
        if job.schedule.kind == "at":
            if job.delete_after_run:
                self._store.jobs = [j for j in self._store.jobs if j.id != job.id]
            else:
                job.enabled = False
                job.state.next_run_at_ms = None
        else:
            # Compute next run
            job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
    
    # ========== Public API ==========
    
    def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
        """List all jobs."""
        store = self._load_store()
        jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
        return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
    
    async def add_job(
        self,
        name: str,
        schedule: CronSchedule,
        message: str,
        deliver: bool = False,
        channel: str | None = None,
        to: str | None = None,
        delete_after_run: bool = False,
    ) -> CronJob:
        """Add a new job."""
        store = self._load_store()
        now = _now_ms()

        job = CronJob(
            id=str(uuid.uuid4())[:8],
            name=name,
            enabled=True,
            schedule=schedule,
            payload=CronPayload(
                kind="agent_turn",
                message=message,
                deliver=deliver,
                channel=channel,
                to=to,
            ),
            state=CronJobState(next_run_at_ms=_compute_next_run(schedule, now)),
            created_at_ms=now,
            updated_at_ms=now,
            delete_after_run=delete_after_run,
        )

        store.jobs.append(job)
        await self._save_store()
        self._arm_timer()

        logger.info(f"Cron: added job '{name}' ({job.id})")
        return job

    async def remove_job(self, job_id: str) -> bool:
        """Remove a job by ID."""
        store = self._load_store()
        before = len(store.jobs)
        store.jobs = [j for j in store.jobs if j.id != job_id]
        removed = len(store.jobs) < before

        if removed:
            await self._save_store()
            self._arm_timer()
            logger.info(f"Cron: removed job {job_id}")

        return removed

    async def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None:
        """Enable or disable a job."""
        store = self._load_store()
        for job in store.jobs:
            if job.id == job_id:
                job.enabled = enabled
                job.updated_at_ms = _now_ms()
                if enabled:
                    job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
                else:
                    job.state.next_run_at_ms = None
                await self._save_store()
                self._arm_timer()
                return job
        return None

    async def run_job(self, job_id: str, force: bool = False) -> bool:
        """Manually run a job."""
        store = self._load_store()
        for job in store.jobs:
            if job.id == job_id:
                if not force and not job.enabled:
                    return False
                await self._execute_job(job)
                await self._save_store()
                self._arm_timer()
                return True
        return False
    
    def status(self) -> dict:
        """Get service status."""
        store = self._load_store()
        return {
            "enabled": self._running,
            "jobs": len(store.jobs),
            "next_wake_at_ms": self._get_next_wake_ms(),
        }
