"""TriggerManager — VidaAgent 主动触发器管理器。

该 operator 位于 L4（sage-middleware），负责将外部触发源统一转换为 VidaEvent，
并调用 ``VidaAgent.handle_event(event)``。

支持触发类型：

- 定时触发（interval / cron）
- Webhook（轻量 HTTP POST）
- 文件监听（watchdog）
- 自定义事件钩子（custom hook）
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)


@dataclass(slots=True)
class VidaEvent:
    """Vida 统一事件格式。"""

    event_type: str
    source: str
    payload: dict[str, Any] = field(default_factory=dict)
    event_id: str = field(default_factory=lambda: uuid.uuid4().hex)
    timestamp: float = field(default_factory=time.time)


class TriggerManager:
    """Vida 主动触发器管理器。"""

    def __init__(self, vida_agent: Any) -> None:
        self._vida_agent = vida_agent
        self._tasks: dict[str, asyncio.Task[None]] = {}
        self._watchdog_observers: dict[str, Any] = {}
        self._server: asyncio.base_events.Server | None = None
        self._webhook_name: str | None = None
        self._webhook_path: str = "/"
        self._webhook_secret: str | None = None
        self._running = False

    async def start(self) -> None:
        """启动 TriggerManager。"""
        self._running = True

    async def stop(self) -> None:
        """停止全部触发器并关闭 webhook 服务。"""
        self._running = False

        for name, observer in list(self._watchdog_observers.items()):
            observer.stop()
            observer.join(timeout=2.0)
            self._watchdog_observers.pop(name, None)

        for name, task in list(self._tasks.items()):
            task.cancel()
            try:
                await asyncio.gather(task, return_exceptions=True)
            finally:
                self._tasks.pop(name, None)

        if self._server is not None:
            self._server.close()
            await self._server.wait_closed()
            self._server = None
            self._webhook_name = None

    async def register_interval_trigger(
        self,
        name: str,
        interval_seconds: float,
        payload_factory: Callable[[], dict[str, Any]] | None = None,
    ) -> None:
        """注册定时触发器。"""
        self._ensure_running()
        self._ensure_unique_name(name)
        if interval_seconds <= 0:
            raise ValueError("interval_seconds must be > 0")

        task = asyncio.create_task(
            self._interval_loop(
                name=name, interval_seconds=interval_seconds, payload_factory=payload_factory
            ),
            name=f"vida_trigger_interval_{name}",
        )
        self._tasks[name] = task

    async def register_cron_trigger(
        self,
        name: str,
        cron_expr: str,
        payload_factory: Callable[[], dict[str, Any]] | None = None,
    ) -> None:
        """注册 cron 触发器。

        支持 6 字段格式：``sec min hour day month weekday``。
        每字段支持：
        - ``*``
        - ``*/N``
        - ``a,b,c``
        - 单值整数
        """
        self._ensure_running()
        self._ensure_unique_name(name)

        cron_fields = self._parse_cron_expr(cron_expr)
        task = asyncio.create_task(
            self._cron_loop(name=name, cron_fields=cron_fields, payload_factory=payload_factory),
            name=f"vida_trigger_cron_{name}",
        )
        self._tasks[name] = task

    async def register_custom_hook_trigger(
        self,
        name: str,
        interval_seconds: float,
        hook: Callable[[], dict[str, Any] | Awaitable[dict[str, Any]]],
    ) -> None:
        """注册自定义钩子触发器（按固定间隔执行 hook）。"""
        self._ensure_running()
        self._ensure_unique_name(name)
        if interval_seconds <= 0:
            raise ValueError("interval_seconds must be > 0")

        task = asyncio.create_task(
            self._custom_hook_loop(name=name, interval_seconds=interval_seconds, hook=hook),
            name=f"vida_trigger_custom_{name}",
        )
        self._tasks[name] = task

    async def register_file_watch_trigger(
        self,
        name: str,
        file_path: str,
        poll_interval_seconds: float = 1.0,
    ) -> None:
        """注册文件监听触发器（watchdog）。"""
        self._ensure_running()
        self._ensure_unique_name(name)
        if poll_interval_seconds <= 0:
            raise ValueError("poll_interval_seconds must be > 0")

        try:
            from watchdog.events import FileSystemEventHandler
            from watchdog.observers import Observer
        except ImportError as exc:
            raise ImportError(
                "watchdog is required for file watch trigger. Install dependency: watchdog>=4.0.0"
            ) from exc

        watch_path = Path(file_path).resolve()
        event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
        loop = asyncio.get_running_loop()

        class _VidaFileHandler(FileSystemEventHandler):
            def on_created(self, event: Any) -> None:
                if not event.is_directory:
                    loop.call_soon_threadsafe(
                        event_queue.put_nowait,
                        {"event": "created", "path": str(Path(event.src_path).resolve())},
                    )

            def on_modified(self, event: Any) -> None:
                if not event.is_directory:
                    loop.call_soon_threadsafe(
                        event_queue.put_nowait,
                        {"event": "modified", "path": str(Path(event.src_path).resolve())},
                    )

            def on_deleted(self, event: Any) -> None:
                if not event.is_directory:
                    loop.call_soon_threadsafe(
                        event_queue.put_nowait,
                        {"event": "deleted", "path": str(Path(event.src_path).resolve())},
                    )

            def on_moved(self, event: Any) -> None:
                if not event.is_directory:
                    loop.call_soon_threadsafe(
                        event_queue.put_nowait,
                        {
                            "event": "moved",
                            "path": str(Path(event.dest_path).resolve()),
                            "src_path": str(Path(event.src_path).resolve()),
                        },
                    )

        observer = Observer(timeout=poll_interval_seconds)
        observer.schedule(_VidaFileHandler(), str(watch_path.parent), recursive=False)
        observer.start()
        self._watchdog_observers[name] = observer

        task = asyncio.create_task(
            self._watchdog_file_loop(
                name=name,
                event_queue=event_queue,
                watch_path=watch_path,
            ),
            name=f"vida_trigger_file_{name}",
        )
        self._tasks[name] = task

    async def register_webhook_trigger(
        self,
        name: str,
        host: str = "127.0.0.1",
        port: int = 8766,
        path: str = "/vida/webhook",
        secret: str | None = None,
    ) -> None:
        """注册 webhook 触发器（HTTP POST）。"""
        self._ensure_running()
        if self._server is not None:
            raise RuntimeError("Webhook trigger already registered")

        normalized_path = path if path.startswith("/") else f"/{path}"
        self._webhook_path = normalized_path
        self._webhook_secret = secret
        self._webhook_name = name

        self._server = await asyncio.start_server(
            self._handle_webhook_connection, host=host, port=port
        )

    async def emit_event(self, event: VidaEvent) -> Any:
        """手动投递事件。"""
        return await self._dispatch_event(event)

    async def _interval_loop(
        self,
        *,
        name: str,
        interval_seconds: float,
        payload_factory: Callable[[], dict[str, Any]] | None,
    ) -> None:
        while self._running:
            payload = payload_factory() if payload_factory is not None else {}
            await self._dispatch_event(
                VidaEvent(event_type="interval", source=name, payload=payload)
            )
            try:
                await asyncio.sleep(interval_seconds)
            except asyncio.CancelledError:
                break

    async def _cron_loop(
        self,
        *,
        name: str,
        cron_fields: list[set[int] | None],
        payload_factory: Callable[[], dict[str, Any]] | None,
    ) -> None:
        last_tick: int | None = None
        while self._running:
            now = datetime.now()
            tick = int(now.timestamp())
            if tick != last_tick and self._cron_match(now, cron_fields):
                last_tick = tick
                payload = payload_factory() if payload_factory is not None else {}
                await self._dispatch_event(
                    VidaEvent(event_type="cron", source=name, payload=payload)
                )
            try:
                await asyncio.sleep(0.25)
            except asyncio.CancelledError:
                break

    async def _custom_hook_loop(
        self,
        *,
        name: str,
        interval_seconds: float,
        hook: Callable[[], dict[str, Any] | Awaitable[dict[str, Any]]],
    ) -> None:
        while self._running:
            try:
                payload_or_coro = hook()
                if asyncio.iscoroutine(payload_or_coro):
                    payload = await payload_or_coro
                else:
                    payload = payload_or_coro
            except Exception as exc:  # noqa: BLE001
                logger.exception("TriggerManager custom hook failed: name=%s", name)
                payload = {"hook_error": str(exc)}

            await self._dispatch_event(
                VidaEvent(event_type="custom_hook", source=name, payload=payload)
            )
            try:
                await asyncio.sleep(interval_seconds)
            except asyncio.CancelledError:
                break

    async def _watchdog_file_loop(
        self,
        *,
        name: str,
        event_queue: asyncio.Queue[dict[str, Any]],
        watch_path: Path,
    ) -> None:
        while self._running:
            try:
                item = await asyncio.wait_for(event_queue.get(), timeout=0.5)
            except asyncio.CancelledError:
                break
            except TimeoutError:
                continue

            event_path = Path(item.get("path", "")).resolve()
            if event_path != watch_path:
                continue

            payload = {
                "path": str(event_path),
                "event": item.get("event", "modified"),
                "exists": event_path.exists(),
                "src_path": item.get("src_path"),
            }
            await self._dispatch_event(
                VidaEvent(event_type="file_watch", source=name, payload=payload)
            )

    async def _dispatch_event(self, event: VidaEvent) -> Any:
        if not hasattr(self._vida_agent, "handle_event"):
            raise RuntimeError("VidaAgent must implement handle_event(event)")

        return await self._vida_agent.handle_event(event)

    async def _handle_webhook_connection(
        self,
        reader: asyncio.StreamReader,
        writer: asyncio.StreamWriter,
    ) -> None:
        try:
            request_data = await reader.read(1024 * 1024)
            method, path, headers, body = self._parse_http_request(request_data)

            if method != "POST":
                self._write_http_response(writer, 405, {"error": "method not allowed"})
                return
            if path != self._webhook_path:
                self._write_http_response(writer, 404, {"error": "not found"})
                return

            if self._webhook_secret is not None:
                token = headers.get("x-vida-secret", "")
                if token != self._webhook_secret:
                    self._write_http_response(writer, 401, {"error": "unauthorized"})
                    return

            payload = self._decode_webhook_payload(body=body, headers=headers)

            source = self._webhook_name or "webhook"
            result = await self._dispatch_event(
                VidaEvent(
                    event_type="webhook",
                    source=source,
                    payload={
                        "request_path": path,
                        "headers": headers,
                        "body": payload,
                    },
                )
            )

            self._write_http_response(
                writer,
                200,
                {"ok": True, "event_type": "webhook", "result_ok": getattr(result, "ok", True)},
            )
        except Exception as exc:  # noqa: BLE001
            logger.exception("TriggerManager webhook handler failed")
            self._write_http_response(writer, 500, {"error": str(exc)})
        finally:
            writer.close()
            await writer.wait_closed()

    @staticmethod
    def _parse_http_request(raw: bytes) -> tuple[str, str, dict[str, str], bytes]:
        if b"\r\n\r\n" not in raw:
            raise ValueError("invalid HTTP request")

        head, body = raw.split(b"\r\n\r\n", 1)
        lines = head.decode("utf-8", errors="replace").split("\r\n")
        request_line = lines[0].strip()
        parts = request_line.split()
        if len(parts) < 2:
            raise ValueError("invalid request line")
        method = parts[0].upper()
        path = parts[1]

        headers: dict[str, str] = {}
        for line in lines[1:]:
            if not line or ":" not in line:
                continue
            key, value = line.split(":", 1)
            headers[key.strip().lower()] = value.strip()
        return method, path, headers, body

    @staticmethod
    def _decode_webhook_payload(body: bytes, headers: dict[str, str]) -> Any:
        content_type = headers.get("content-type", "")
        if "application/json" in content_type:
            try:
                return json.loads(body.decode("utf-8"))
            except json.JSONDecodeError:
                return {"raw": body.decode("utf-8", errors="replace")}
        return body.decode("utf-8", errors="replace")

    @staticmethod
    def _write_http_response(
        writer: asyncio.StreamWriter,
        status_code: int,
        payload: dict[str, Any],
    ) -> None:
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        reason = {
            200: "OK",
            401: "Unauthorized",
            404: "Not Found",
            405: "Method Not Allowed",
            500: "Internal Server Error",
        }.get(status_code, "OK")

        header = (
            f"HTTP/1.1 {status_code} {reason}\r\n"
            "Content-Type: application/json; charset=utf-8\r\n"
            f"Content-Length: {len(body)}\r\n"
            "Connection: close\r\n\r\n"
        ).encode()
        writer.write(header + body)

    def _ensure_running(self) -> None:
        if not self._running:
            raise RuntimeError("TriggerManager is not started")

    def _ensure_unique_name(self, name: str) -> None:
        if name in self._tasks:
            raise RuntimeError(f"Trigger name already exists: {name}")

    @staticmethod
    def _parse_cron_expr(expr: str) -> list[set[int] | None]:
        parts = expr.strip().split()
        if len(parts) != 6:
            raise ValueError("cron_expr must contain 6 fields: sec min hour day month weekday")

        ranges = [
            (0, 59),
            (0, 59),
            (0, 23),
            (1, 31),
            (1, 12),
            (0, 6),
        ]

        parsed: list[set[int] | None] = []
        for idx, token in enumerate(parts):
            min_v, max_v = ranges[idx]
            if token == "*":
                parsed.append(None)
                continue

            values: set[int] = set()
            for piece in token.split(","):
                piece = piece.strip()
                if piece.startswith("*/"):
                    step = int(piece[2:])
                    if step <= 0:
                        raise ValueError(f"Invalid cron step: {piece}")
                    values.update(range(min_v, max_v + 1, step))
                else:
                    value = int(piece)
                    if value < min_v or value > max_v:
                        raise ValueError(f"Cron value out of range: {piece}")
                    values.add(value)

            parsed.append(values)

        return parsed

    @staticmethod
    def _cron_match(now: datetime, fields: list[set[int] | None]) -> bool:
        values = [
            now.second,
            now.minute,
            now.hour,
            now.day,
            now.month,
            now.weekday(),
        ]
        for field_set, value in zip(fields, values, strict=True):
            if field_set is None:
                continue
            if value not in field_set:
                return False
        return True

    @property
    def is_running(self) -> bool:
        """TriggerManager 是否运行中。"""
        return self._running
