"""Pulse scheduling — brainfile protocol native.

All scheduling (reminders, recurring tasks, autonomous pulses) is stored as
brainfile pulse task files in ``brainfile/board/``. Completed one-shot pulses
are archived to ``brainfile/logs/``.

The PulseRunner scans the board directory on a timer and dispatches due tasks.
No SQLite — just YAML frontmatter markdown files.
"""

import asyncio
import json
import os
import sys
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

from brainfile import Task as BfTask
from brainfile import generate_next_file_task_id as _bf_next_id
from brainfile import parse_task_content as _bf_parse
from brainfile import read_tasks_dir as _bf_read_tasks_dir
from brainfile import serialize_task_content as _bf_serialize

from otto.config import OTTO_HOME
from otto.delegation import BOARD_DIR, LOGS_DIR
from otto.log import get_logger
from otto.skills import BUILTIN_SKILLS_DIR, discover_many

_log = get_logger("otto.autonomy")

AUTONOMY_PULSE_CHAT_ID = "__autonomy__"
_HEARTBEAT_PAYLOAD_PREFIX = "__heartbeat__:"
_DEFAULT_HEARTBEAT_TIMEOUT_SECONDS = 20
_HEARTBEAT_MAX_PAYLOAD_BYTES = 64_000


@dataclass(frozen=True)
class HeartbeatSkillResult:
    """Normalized deterministic heartbeat skill output."""

    status: str
    summary: str = "Heartbeat skill completed"
    details: dict[str, Any] | None = None
    action: dict[str, Any] | None = None
    next_due_at: datetime | None = None
    confidence: float | None = None


@dataclass(frozen=True)
class HeartbeatSkillSpec:
    """Entrypoint metadata extracted from a heartbeat-capable skill."""

    name: str
    entrypoint: Path
    timeout_seconds: int
    skill_dir: Path


def _utc_now() -> datetime:
    return datetime.now(UTC)


def _parse_iso(value: str | None) -> datetime | None:
    if not value:
        return None
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError:
        return None
    if parsed.tzinfo is None:
        return parsed.replace(tzinfo=UTC)
    return parsed.astimezone(UTC)


def _clamp_timeout_seconds(value: Any) -> int:
    try:
        timeout = int(value)
    except (TypeError, ValueError):
        timeout = _DEFAULT_HEARTBEAT_TIMEOUT_SECONDS
    return max(5, min(timeout, 300))


def _normalize_mode(value: Any) -> str:
    mode = str(value or "prompt").strip().lower()
    return mode if mode in {"prompt", "skill"} else "prompt"


def _parse_timeout_seconds(value: Any) -> int | None:
    if value is None:
        return None
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value
    if isinstance(value, float):
        return int(value)
    if isinstance(value, str):
        candidate = value.strip()
        if candidate.isdigit():
            return int(candidate)
    return None


def _as_text(value: Any) -> str | None:
    if value is None:
        return None
    if isinstance(value, str):
        candidate = value.strip()
        return candidate or None
    return str(value)


def _heartbeat_skill_dirs() -> list[Path]:
    roots: list[Path] = []
    seen: set[Path] = set()

    local = OTTO_HOME / "skills"
    for raw in (local, BUILTIN_SKILLS_DIR):
        try:
            resolved = raw.expanduser().resolve(strict=False)
        except Exception:
            resolved = raw
        if resolved in seen:
            continue
        seen.add(resolved)
        roots.append(resolved)

    return roots


def _resolve_heartbeat_skill(task: "PulseTask") -> HeartbeatSkillSpec | None:
    if task.mode != "skill":
        return None

    skill_dir: Path | None = None
    metadata: dict[str, Any] = {}
    skill_name = task.skill_name or "heartbeat"

    if task.skill_name:
        candidates = [
            skill
            for skill in discover_many(_heartbeat_skill_dirs())
            if skill.name == task.skill_name
        ]
        if candidates:
            skill = candidates[0]
            skill_dir = skill.path
            skill_name = skill.name
            metadata = skill.metadata or {}

    entrypoint_raw = task.skill_entrypoint or _as_text(metadata.get("heartbeat.entrypoint"))
    if not entrypoint_raw:
        return None

    entrypoint_path = Path(entrypoint_raw).expanduser()
    if not entrypoint_path.is_absolute():
        base_dir = skill_dir or OTTO_HOME
        entrypoint_path = (base_dir / entrypoint_path).resolve(strict=False)
    else:
        entrypoint_path = entrypoint_path.resolve(strict=False)
    if not entrypoint_path.is_file():
        return None

    timeout_raw = (
        task.timeout_seconds
        if task.timeout_seconds is not None
        else metadata.get("heartbeat.timeout_seconds")
    )
    timeout_seconds = _clamp_timeout_seconds(timeout_raw)
    return HeartbeatSkillSpec(
        name=skill_name,
        entrypoint=entrypoint_path,
        timeout_seconds=timeout_seconds,
        skill_dir=(skill_dir or entrypoint_path.parent).resolve(strict=False),
    )


def _heartbeat_state_dir(task: "PulseTask") -> Path:
    return (OTTO_HOME / "heartbeat_state" / task.id).resolve(strict=False)


def _heartbeat_env(task: "PulseTask", spec: HeartbeatSkillSpec) -> dict[str, str]:
    state_dir = _heartbeat_state_dir(task)
    state_dir.mkdir(parents=True, exist_ok=True)
    return {
        "OTTO_HEARTBEAT_TASK_ID": task.id,
        "OTTO_HEARTBEAT_CHAT_ID": task.chat_id,
        "OTTO_HEARTBEAT_TITLE": task.title,
        "OTTO_HEARTBEAT_DUE_AT": task.due_at.isoformat(),
        "OTTO_HEARTBEAT_REPEAT": task.repeat or "",
        "OTTO_HEARTBEAT_SKILL_NAME": spec.name,
        "OTTO_HEARTBEAT_SKILL_DIR": str(spec.skill_dir),
        "OTTO_HEARTBEAT_ENTRYPOINT": str(spec.entrypoint),
        "OTTO_HEARTBEAT_STATE_DIR": str(state_dir),
    }


def _parse_heartbeat_result(output: str) -> HeartbeatSkillResult | None:
    if not output:
        return None

    candidate = output.strip()
    if not candidate:
        return None

    if len(candidate.encode("utf-8", errors="ignore")) > _HEARTBEAT_MAX_PAYLOAD_BYTES:
        return None

    payload: Any = None
    for raw_payload in (candidate, *(line.strip() for line in reversed(candidate.splitlines()))):
        if not raw_payload:
            continue
        try:
            payload = json.loads(raw_payload)
            break
        except json.JSONDecodeError:
            continue
    if not isinstance(payload, dict):
        return None

    status = str(payload.get("status", "")).strip().lower()
    action_payload = payload.get("action") if isinstance(payload.get("action"), dict) else None
    if not status and isinstance(payload.get("action"), str):
        status = str(payload.get("action", "")).strip().lower()
    if status not in {"idle", "trigger"}:
        return None

    summary = str(payload.get("summary", "")).strip()
    if not summary:
        summary = "Heartbeat skill completed"

    details_raw = payload.get("details")
    details = details_raw if isinstance(details_raw, dict) else {}

    next_due_at = _parse_iso(payload.get("next_due_at"))
    confidence = None
    confidence_raw = payload.get("confidence")
    if isinstance(confidence_raw, (int, float)):
        confidence = float(confidence_raw)

    return HeartbeatSkillResult(
        status=status,
        summary=summary,
        details=details,
        action=action_payload,
        next_due_at=next_due_at,
        confidence=confidence,
    )


def _heartbeat_payload(task: "PulseTask", result: HeartbeatSkillResult) -> dict[str, Any]:
    payload: dict[str, Any] = {
        "kind": "heartbeat_trigger",
        "task_id": task.id,
        "mode": "skill",
        "skill": task.skill_name,
        "summary": result.summary,
        "details": result.details,
    }
    if result.action is not None:
        payload["action"] = result.action
    if result.confidence is not None:
        payload["confidence"] = result.confidence
    return payload


# ---------------------------------------------------------------------------
# Brainfile task helpers
# ---------------------------------------------------------------------------


def _update_frontmatter_field(path: Path, keys: list[str], value: Any) -> None:
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return
    doc = _bf_parse(text)
    if not doc:
        return
    fm = doc.task.model_dump(by_alias=True)
    target = fm
    for key in keys[:-1]:
        if key not in target or not isinstance(target[key], dict):
            target[key] = {}
        target = target[key]
    target[keys[-1]] = value
    task = BfTask.model_validate(fm)
    content = _bf_serialize(task, doc.body)
    path.write_text(content, encoding="utf-8")


# ---------------------------------------------------------------------------
# PulseTask dataclass
# ---------------------------------------------------------------------------


@dataclass
class PulseTask:
    """A brainfile task of type 'pulse'."""

    id: str
    title: str
    due_at: datetime
    repeat: str | None
    chat_id: str
    file_path: Path
    mode: str = "prompt"
    skill_name: str | None = None
    skill_entrypoint: str | None = None
    timeout_seconds: int | None = None


# ---------------------------------------------------------------------------
# Pulse CRUD — create, scan, list, cancel, archive
# ---------------------------------------------------------------------------


def create_pulse_task(
    board: Path,
    *,
    title: str,
    due_at: datetime,
    chat_id: str,
    repeat: str | None = None,
    logs: Path | None = None,
    mode: str = "prompt",
    skill_name: str | None = None,
    skill_entrypoint: str | None = None,
    timeout_seconds: int | None = None,
) -> PulseTask:
    """Create a new pulse task file in the board directory."""
    board.mkdir(parents=True, exist_ok=True)
    task_id = _bf_next_id(str(board), str(logs) if logs else None, "pulse")
    normalized_mode = _normalize_mode(mode)

    if due_at.tzinfo is None:
        due_at = due_at.replace(tzinfo=UTC)

    frontmatter: dict[str, Any] = {
        "id": task_id,
        "title": title,
        "type": "pulse",
        "column": "todo",
        "due_at": due_at.isoformat(),
        "repeat": repeat,
        "chat_id": chat_id,
        "mode": normalized_mode,
        "contract": {"status": "ready"},
    }
    if skill_name is not None:
        frontmatter["skill_name"] = skill_name
    if skill_entrypoint is not None:
        frontmatter["skill_entrypoint"] = skill_entrypoint
    if timeout_seconds is not None:
        frontmatter["timeout_seconds"] = timeout_seconds

    path = board / f"{task_id}.md"
    task = BfTask.model_validate(frontmatter)
    content = _bf_serialize(task, "")
    path.write_text(content, encoding="utf-8")

    return PulseTask(
        id=task_id,
        title=title,
        due_at=due_at,
        repeat=repeat,
        chat_id=chat_id,
        file_path=path,
        mode=normalized_mode,
        skill_name=skill_name,
        skill_entrypoint=skill_entrypoint,
        timeout_seconds=timeout_seconds,
    )


def scan_pulse_tasks(brainfile_board: Path) -> list[PulseTask]:
    """Read pulse-type tasks from the brainfile board directory.

    Returns only tasks with contract.status == 'ready' and a valid due_at.
    """
    if not brainfile_board.is_dir():
        return []

    tasks: list[PulseTask] = []
    for doc in _bf_read_tasks_dir(str(brainfile_board)):
        fm = doc.task.model_dump(by_alias=True)
        if fm.get("type") != "pulse":
            continue
        contract = fm.get("contract") or {}
        if not isinstance(contract, dict):
            continue
        if contract.get("status") != "ready":
            continue

        due_at = _parse_iso(fm.get("due_at"))
        if due_at is None:
            continue

        tasks.append(
            PulseTask(
                id=fm.get("id", ""),
                title=fm.get("title", ""),
                due_at=due_at,
                repeat=fm.get("repeat"),
                chat_id=fm.get("chat_id", AUTONOMY_PULSE_CHAT_ID),
                file_path=(
                    Path(doc.file_path)
                    if doc.file_path
                    else brainfile_board / f"{fm.get('id', '')}.md"
                ),
                mode=_normalize_mode(fm.get("mode")),
                skill_name=_as_text(fm.get("skill_name")),
                skill_entrypoint=_as_text(fm.get("skill_entrypoint")),
                timeout_seconds=_parse_timeout_seconds(fm.get("timeout_seconds")),
            )
        )
    return tasks


def list_pulse_tasks(brainfile_board: Path, chat_id: str | None = None) -> list[PulseTask]:
    """List all pulse tasks (any ready status), optionally filtered by chat_id."""
    if not brainfile_board.is_dir():
        return []

    tasks: list[PulseTask] = []
    for doc in _bf_read_tasks_dir(str(brainfile_board)):
        fm = doc.task.model_dump(by_alias=True)
        if fm.get("type") != "pulse":
            continue

        contract = fm.get("contract") or {}
        if not isinstance(contract, dict):
            continue
        status = contract.get("status")
        if status not in ("ready", "in_progress"):
            continue

        due_at = _parse_iso(fm.get("due_at"))
        if due_at is None:
            continue

        task_chat_id = fm.get("chat_id", AUTONOMY_PULSE_CHAT_ID)
        if chat_id is not None and task_chat_id != chat_id:
            continue

        tasks.append(
            PulseTask(
                id=fm.get("id", ""),
                title=fm.get("title", ""),
                due_at=due_at,
                repeat=fm.get("repeat"),
                chat_id=task_chat_id,
                file_path=(
                    Path(doc.file_path)
                    if doc.file_path
                    else brainfile_board / f"{fm.get('id', '')}.md"
                ),
                mode=_normalize_mode(fm.get("mode")),
                skill_name=_as_text(fm.get("skill_name")),
                skill_entrypoint=_as_text(fm.get("skill_entrypoint")),
                timeout_seconds=_parse_timeout_seconds(fm.get("timeout_seconds")),
            )
        )
    return sorted(tasks, key=lambda t: t.due_at)


def cancel_pulse_task(brainfile_board: Path, task_id: str, logs: Path | None = None) -> bool:
    """Cancel (archive) a pulse task. Returns True if found and cancelled."""
    path = brainfile_board / f"{task_id}.md"
    if not path.exists():
        return False

    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return False
    doc = _bf_parse(text)
    if doc is None:
        return False
    fm = doc.task.model_dump(by_alias=True)
    if fm.get("type") != "pulse":
        return False

    if logs is not None:
        logs.mkdir(parents=True, exist_ok=True)
        _update_frontmatter_field(path, ["contract", "status"], "cancelled")
        _update_frontmatter_field(path, ["column"], "done")
        dst = logs / path.name
        try:
            # Read updated content, write to logs, remove from board
            dst.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
            path.unlink()
        except OSError:
            pass
    else:
        path.unlink(missing_ok=True)
    return True


def _archive_pulse(task: PulseTask, logs: Path | None) -> None:
    """Move a completed one-shot pulse from board to logs."""
    if logs is None:
        return
    logs.mkdir(parents=True, exist_ok=True)
    dst = logs / task.file_path.name
    try:
        dst.write_text(task.file_path.read_text(encoding="utf-8"), encoding="utf-8")
        task.file_path.unlink()
    except OSError as exc:
        _log.warning("Could not archive pulse %s: %s", task.id, exc)


def check_due_pulses(brainfile_board: Path, now: datetime | None = None) -> list[PulseTask]:
    """Return pulse tasks that are due (due_at <= now)."""
    now = now or _utc_now()
    return [t for t in scan_pulse_tasks(brainfile_board) if t.due_at <= now]


def mark_pulse_in_progress(task: PulseTask) -> None:
    _update_frontmatter_field(task.file_path, ["contract", "status"], "in_progress")


def mark_pulse_ready(task: PulseTask) -> None:
    _update_frontmatter_field(task.file_path, ["contract", "status"], "ready")


def mark_pulse_failed(task: PulseTask) -> None:
    _update_frontmatter_field(task.file_path, ["contract", "status"], "failed")


def set_pulse_due_at(task: PulseTask, due_at: datetime) -> None:
    if due_at.tzinfo is None:
        due_at = due_at.replace(tzinfo=UTC)
    _update_frontmatter_field(task.file_path, ["due_at"], due_at.isoformat())


def mark_pulse_done(task: PulseTask, *, logs: Path | None = None) -> None:
    """Mark a pulse task as done, or reset it for the next cycle if recurring."""
    if task.repeat:
        from croniter import croniter

        next_due = croniter(task.repeat, task.due_at).get_next(datetime)
        if next_due.tzinfo is None:
            next_due = next_due.replace(tzinfo=UTC)
        _update_frontmatter_field(task.file_path, ["due_at"], next_due.isoformat())
        _update_frontmatter_field(task.file_path, ["contract", "status"], "ready")
    else:
        _update_frontmatter_field(task.file_path, ["contract", "status"], "done")
        _update_frontmatter_field(task.file_path, ["column"], "done")
        _archive_pulse(task, logs)


# ---------------------------------------------------------------------------
# PulseRunner — the universal pulse dispatcher
# ---------------------------------------------------------------------------


class PulseRunner:
    """Scans brainfile board for due pulse tasks and dispatches them.

    Replaces both the SQLite scheduler and the old AutonomyKernel.
    Runs on a simple asyncio timer in the daemon.
    """

    _idle_reschedule = timedelta(minutes=30)

    def __init__(
        self,
        *,
        config: Any,
        dispatch: Any,
    ) -> None:
        self._board = BOARD_DIR
        self._logs = LOGS_DIR
        self._config = config
        self._dispatch = dispatch

    @property
    def board(self) -> Path:
        return self._board

    @property
    def logs(self) -> Path | None:
        return self._logs

    @property
    def enabled(self) -> bool:
        autonomy = getattr(self._config, "autonomy", None)
        return bool(getattr(autonomy, "enabled", False))

    @property
    def mode(self) -> str:
        autonomy = getattr(self._config, "autonomy", None)
        mode = str(getattr(autonomy, "mode", "passive")).strip().lower()
        if mode not in {"passive", "notify"}:
            return "passive"
        return mode

    @property
    def pulse_seconds(self) -> int:
        autonomy = getattr(self._config, "autonomy", None)
        pulse_minutes = int(getattr(autonomy, "pulse_minutes", 5))
        return max(30, pulse_minutes * 60)

    async def _dispatch_prompt(self, task: PulseTask) -> bool:
        prompt = task.title
        if not prompt.startswith("__notify__:"):
            prompt = f"Pulse task due: {task.title}"
        delivered = await self._dispatch(task.chat_id, prompt)
        return delivered is not False

    async def _run_skill_mode(self, task: PulseTask) -> tuple[bool, bool]:
        spec = _resolve_heartbeat_skill(task)
        if spec is None:
            _log.warning(
                "heartbeat skill metadata missing",
                task_id=task.id,
                skill=task.skill_name,
            )
            return False, False

        env = {**dict(os.environ), **dict(_heartbeat_env(task, spec))}
        argv = [str(spec.entrypoint)]
        if spec.entrypoint.suffix == ".py":
            argv = [sys.executable, str(spec.entrypoint)]
        try:
            proc = await asyncio.create_subprocess_exec(
                *argv,
                cwd=str(spec.skill_dir),
                env=env,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
        except OSError as exc:
            _log.warning(
                "heartbeat skill could not start",
                task_id=task.id,
                skill=spec.name,
                error=str(exc),
            )
            return False, False
        try:
            stdout, stderr = await asyncio.wait_for(
                proc.communicate(), timeout=spec.timeout_seconds
            )
        except TimeoutError:
            proc.kill()
            await proc.wait()
            _log.warning("heartbeat skill timed out", task_id=task.id, skill=spec.name)
            return False, False

        if proc.returncode != 0:
            err = ((stderr or b"") + (stdout or b"")).decode("utf-8", errors="replace")
            _log.warning(
                "heartbeat skill failed", task_id=task.id, skill=spec.name, error=err[:240]
            )
            return False, False

        result = _parse_heartbeat_result((stdout or b"").decode("utf-8", errors="replace"))
        if result is None:
            _log.warning("heartbeat skill returned invalid JSON", task_id=task.id, skill=spec.name)
            return False, False

        if result.status == "idle":
            next_due = result.next_due_at or (_utc_now() + self._idle_reschedule)
            set_pulse_due_at(task, next_due)
            mark_pulse_ready(task)
            _log.info("heartbeat skill idle", task_id=task.id, skill=spec.name)
            return False, True

        payload = _heartbeat_payload(task, result)
        delivered = await self._dispatch(
            task.chat_id,
            _HEARTBEAT_PAYLOAD_PREFIX + json.dumps(payload, ensure_ascii=False),
        )
        if delivered is False:
            return False, False
        return True, False

    async def _execute_task(self, task: PulseTask) -> tuple[bool, bool]:
        if task.mode == "skill":
            return await self._run_skill_mode(task)
        return await self._dispatch_prompt(task), False

    async def trigger_now(self, task_id: str, *, skip_next: bool = False) -> str:
        """Manually trigger a pulse task. Returns status string."""
        tasks = list_pulse_tasks(self._board)
        task = next((t for t in tasks if t.id == task_id), None)
        if task is None:
            return "not_found"

        acted, idle = await self._execute_task(task)
        if idle:
            return "idle"
        if not acted:
            return "dispatch_failed"

        if not task.repeat or skip_next:
            mark_pulse_done(task, logs=self._logs)

        return "triggered"

    async def run_pulse(self) -> dict[str, int]:
        now = _utc_now()
        due = check_due_pulses(self._board, now)
        if not due:
            return {"checked": 0, "acted": 0, "skipped": 0}

        stats = {"checked": len(due), "acted": 0, "skipped": 0}

        for task in due:
            try:
                if task.chat_id == AUTONOMY_PULSE_CHAT_ID:
                    if not self.enabled:
                        stats["skipped"] += 1
                        continue
                    if self.mode == "passive":
                        _log.info("pulse due (passive)", task_id=task.id, title=task.title)
                        stats["skipped"] += 1
                        continue

                mark_pulse_in_progress(task)
                acted, idle = await self._execute_task(task)

                if idle:
                    stats["skipped"] += 1
                    continue

                if not acted:
                    mark_pulse_ready(task)
                    _log.warning("pulse dispatch failed", task_id=task.id)
                    stats["skipped"] += 1
                    continue

                mark_pulse_done(task, logs=self._logs)
                stats["acted"] += 1
            except Exception as exc:
                if task.repeat:
                    mark_pulse_ready(task)
                else:
                    mark_pulse_failed(task)
                _log.warning("pulse error", task_id=task.id, error=str(exc))
                stats["skipped"] += 1

        return stats
