"""
Agent delegation data layer — Brainfile v2 protocol compliant.

Stores delegated tasks as markdown files with YAML frontmatter following
the Brainfile v2 specification (https://brainfile.md/reference/types).

Directory layout (under OTTO_HOME):
    brainfile/               — Brainfile v2 workspace (Otto-owned root)
        brainfile.md         — Board configuration (v2 board schema)
        board/               — Active task .md files
        logs/                — Completed task .md files
    state/events.jsonl       — Otto internal audit trail (append-only)

    On every startup, active board tasks are reloaded to restore persisted state.
    Jobs that were in-progress at shutdown are marked failed for re-queuing.

Schemas:
    Board:    https://brainfile.md/v2/board.json
    Task:     https://brainfile.md/v2/task.json
    Contract: https://brainfile.md/v2/contract.json
"""

import json
import os
import re
import shutil
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from pathlib import Path
from typing import Any, Literal

from brainfile import AgentInstructions as BfAgentInstructions
from brainfile import BoardConfig as BfBoardConfig
from brainfile import ColumnConfig as BfColumnConfig
from brainfile import Task as BfTask
from brainfile import ensure_dirs as _bf_ensure_dirs
from brainfile import extract_description as _bf_extract_description
from brainfile import extract_log as _bf_extract_log
from brainfile import generate_next_file_task_id as _bf_next_id
from brainfile import parse_task_content as _bf_parse
from brainfile import serialize_task_content as _bf_serialize
from brainfile import write_board_config as _bf_write_board_config

from otto.config import OTTO_HOME
from otto.log import get_logger

_log = get_logger("otto.delegation")

# ---------------------------------------------------------------------------
# Paths — Brainfile v2 directory layout
# ---------------------------------------------------------------------------

_BRAINFILE_ROOT = OTTO_HOME / "brainfile"  # Otto-owned Brainfile workspace root
BOARD_DIR = _BRAINFILE_ROOT / "board"  # Active task .md files
LOGS_DIR = _BRAINFILE_ROOT / "logs"  # Completed task .md files
BOARD_CONFIG = _BRAINFILE_ROOT / "brainfile.md"  # Board configuration

STATE_DIR = OTTO_HOME / "state"  # Otto internal state (not part of brainfile spec)
EVENTS_FILE = STATE_DIR / "events.jsonl"  # Append-only audit trail

_TASK_ID_RE = re.compile(r"^task-(\d+)\.md$")

# Legacy paths (pre-migration, top-level board/ and logs/)
_LEGACY_BOARD_DIR = OTTO_HOME / "board"
_LEGACY_LOGS_DIR = OTTO_HOME / "logs"
_LEGACY_BOARD_CONFIG = OTTO_HOME / "brainfile.md"
_LEGACY_BRAINFILE_ROOT = OTTO_HOME / ".brainfile"
_LEGACY_BRAINFILE_BOARD_DIR = _LEGACY_BRAINFILE_ROOT / "board"
_LEGACY_BRAINFILE_LOGS_DIR = _LEGACY_BRAINFILE_ROOT / "logs"
_LEGACY_BRAINFILE_BOARD_CONFIG = _LEGACY_BRAINFILE_ROOT / "brainfile.md"


def _as_dict(val: Any) -> dict:
    """Safely coerce a YAML value to dict (handles None, scalars, wrong types)."""
    return val if isinstance(val, dict) else {}


def _as_list(val: Any) -> list:
    """Safely coerce a YAML value to list (handles None, scalars, wrong types)."""
    return val if isinstance(val, list) else []


def _bf_extract_log_entries(body: str) -> list[str]:
    section = _bf_extract_log(body)
    if not section:
        return []
    return [
        line.lstrip("- ").strip() for line in section.splitlines() if line.strip().startswith("-")
    ]


def _migrate_task_files(source: Path, destination: Path) -> int:
    moved = 0
    if not source.exists():
        return moved
    destination.mkdir(parents=True, exist_ok=True)
    for path in source.glob("task-*.md"):
        dst = destination / path.name
        if dst.exists():
            continue
        try:
            path.rename(dst)
            moved += 1
        except OSError as exc:
            _log.warning("Could not migrate %s: %s", path, exc)
    return moved


def _prune_empty_dir(path: Path) -> None:
    try:
        if path.exists() and not any(path.iterdir()):
            path.rmdir()
    except OSError:
        pass


def _ensure_dirs() -> None:
    _migrate_legacy_layout()
    _bf_ensure_dirs(str(BOARD_CONFIG))
    STATE_DIR.mkdir(parents=True, exist_ok=True)


def _migrate_legacy_layout() -> None:
    """One-time migration: move task files from legacy layouts into brainfile/.

    Legacy layout had board/ and logs/ at OTTO_HOME root alongside app logs.
    New layout nests them under brainfile/ for spec compliance and to avoid
    mixing completed task .md files with application .log files.
    """
    moved = 0

    if _LEGACY_BRAINFILE_ROOT.exists() and _LEGACY_BRAINFILE_ROOT != _BRAINFILE_ROOT:
        if not _BRAINFILE_ROOT.exists():
            _BRAINFILE_ROOT.parent.mkdir(parents=True, exist_ok=True)
            try:
                shutil.move(str(_LEGACY_BRAINFILE_ROOT), str(_BRAINFILE_ROOT))
                moved += 1
                _log.debug("Migrated %s -> %s", _LEGACY_BRAINFILE_ROOT, _BRAINFILE_ROOT)
            except OSError as exc:
                _log.warning("Could not migrate brainfile root: %s", exc)
        else:
            if _LEGACY_BRAINFILE_BOARD_CONFIG.exists() and not BOARD_CONFIG.exists():
                try:
                    _LEGACY_BRAINFILE_BOARD_CONFIG.rename(BOARD_CONFIG)
                    moved += 1
                    _log.debug("Migrated %s -> %s", _LEGACY_BRAINFILE_BOARD_CONFIG, BOARD_CONFIG)
                except OSError as exc:
                    _log.warning("Could not migrate board config: %s", exc)
            moved += _migrate_task_files(_LEGACY_BRAINFILE_BOARD_DIR, BOARD_DIR)
            moved += _migrate_task_files(_LEGACY_BRAINFILE_LOGS_DIR, LOGS_DIR)
            _prune_empty_dir(_LEGACY_BRAINFILE_BOARD_DIR)
            _prune_empty_dir(_LEGACY_BRAINFILE_LOGS_DIR)
            _prune_empty_dir(_LEGACY_BRAINFILE_ROOT)

    # Migrate brainfile.md
    if _LEGACY_BOARD_CONFIG.exists() and not BOARD_CONFIG.exists():
        BOARD_CONFIG.parent.mkdir(parents=True, exist_ok=True)
        try:
            _LEGACY_BOARD_CONFIG.rename(BOARD_CONFIG)
            moved += 1
            _log.debug("Migrated %s -> %s", _LEGACY_BOARD_CONFIG, BOARD_CONFIG)
        except OSError as exc:
            _log.warning("Could not migrate board config: %s", exc)

    # Migrate task .md files from legacy board/
    if _LEGACY_BOARD_DIR.exists() and _LEGACY_BOARD_DIR != BOARD_DIR:
        moved += _migrate_task_files(_LEGACY_BOARD_DIR, BOARD_DIR)

    # Migrate task .md files from legacy logs/ (shared with app .log files)
    if _LEGACY_LOGS_DIR.exists() and _LEGACY_LOGS_DIR != LOGS_DIR:
        moved += _migrate_task_files(_LEGACY_LOGS_DIR, LOGS_DIR)

    _prune_empty_dir(_LEGACY_BOARD_DIR / "logs")
    _prune_empty_dir(_LEGACY_BOARD_DIR)

    if moved:
        _log.info("Migrated %d legacy item(s) into brainfile/", moved)


def _ensure_board_config() -> None:
    """Create the board configuration file if it doesn't exist."""
    if BOARD_CONFIG.exists():
        return
    config = BfBoardConfig(
        title="Otto Delegation Board",
        type="board",
        columns=[
            BfColumnConfig(id="todo", title="Todo"),
            BfColumnConfig(id="in-progress", title="In Progress"),
            BfColumnConfig(id="review", title="Review"),
        ],
        agent=BfAgentInstructions(
            instructions=[
                "Otto manages this board automatically",
                "Task files are created and updated by the orchestrator",
            ]
        ),
    )
    body = (
        "Otto's delegation board. Tasks are created when the main agent"
        " delegates work to sub-agents.\n"
    )
    try:
        _bf_write_board_config(str(BOARD_CONFIG), config, body)
    except OSError as exc:
        _log.warning("Could not write board config: %s", exc)


# ---------------------------------------------------------------------------
# Markdown parsing helpers
# ---------------------------------------------------------------------------


def _extract_section(body: str, heading: str) -> str | None:
    """Extract content under a ## heading from markdown body."""
    pattern = re.compile(rf"^## {re.escape(heading)}\s*$", re.MULTILINE)
    match = pattern.search(body)
    if not match:
        return None
    start = match.end()
    while start < len(body) and body[start] == "\n":
        start += 1
    next_match = re.search(r"^## ", body[start:], re.MULTILINE)
    end = start + next_match.start() if next_match else len(body)
    return body[start:end].strip()


def _parse_dt(val: Any) -> datetime | None:
    """Parse an optional datetime from YAML (may be str or datetime)."""
    if val is None:
        return None
    if isinstance(val, datetime):
        return val if val.tzinfo else val.replace(tzinfo=UTC)
    return datetime.fromisoformat(str(val))


# ---------------------------------------------------------------------------
# Job lifecycle FSM — Brainfile contract statuses
# ---------------------------------------------------------------------------


class JobStatus(StrEnum):
    """
    Contract lifecycle statuses per Brainfile v2 contract schema.

    Standard: ready, in_progress, delivered, done, failed, blocked
    Otto extensions: cancelled, reworking

    Transitions:
        ready -> in_progress -> delivered -> done
                             -> reworking -> in_progress  (validation failed, retries remain)
                             -> failed                    (exhausted all attempts)
        in_progress -> cancelled
    """

    READY = "ready"
    IN_PROGRESS = "in_progress"
    DELIVERED = "delivered"
    DONE = "done"
    FAILED = "failed"
    BLOCKED = "blocked"
    CANCELLED = "cancelled"
    REWORKING = "reworking"


# Column mapping for active contract statuses
_STATUS_TO_COLUMN: dict[JobStatus, str] = {
    JobStatus.READY: "todo",
    JobStatus.IN_PROGRESS: "in-progress",
    JobStatus.DELIVERED: "review",
    JobStatus.REWORKING: "in-progress",
    JobStatus.FAILED: "todo",
    JobStatus.BLOCKED: "todo",
}


# ---------------------------------------------------------------------------
# DelegationContract
# ---------------------------------------------------------------------------


@dataclass
class DelegationContract:
    """
    Delegation contract following Brainfile v2 contract schema.

    The contract defines what a sub-agent must deliver and how to verify it.
    Context lives in task.relatedFiles and the markdown body, not here.

    Schema: https://brainfile.md/v2/contract.json
    """

    task: str
    deliverables: list[str] = field(default_factory=list)
    constraints: list[str] = field(default_factory=list)
    out_of_scope: list[str] = field(default_factory=list)
    validation_cmds: list[str] = field(default_factory=list)
    context_files: list[str] = field(default_factory=list)
    model: str | None = None
    timeout: int | None = None
    on_child_failure: Literal["retry_once", "skip", "abort"] = "retry_once"
    on_dependency_failure: Literal["retry_once", "skip", "abort"] = "retry_once"

    def to_prompt(
        self,
        *,
        feedback: str | None = None,
        attempt: int = 1,
        child_results: list[dict[str, str]] | None = None,
    ) -> str:
        """Render the contract as a structured prompt for the sub-agent.

        Args:
            feedback:      Rework guidance from a previous failed attempt.
            attempt:       Current attempt number (1-indexed).
            child_results: Results from child tasks (for parent/synthesis jobs).
                           Each dict has 'id', 'task', and 'result' keys.
        """
        silent_rules = [
            "Autonomous execution rules (no human is reading your intermediate output):",
            "- Execute tools immediately and silently. Do not narrate your plan or say 'I will now...'",
            "- Do not echo back file contents or code blocks — the tools already record changes.",
            "- Do not run verification steps unless the outcome is genuinely uncertain.",
            "- Output only a final summary of deliverables when the task is completely finished.",
        ]

        if attempt > 1 and feedback:
            lines = [
                "You are a delegated sub-agent with full tool access.",
                "A previous attempt at this task FAILED. Read the feedback carefully and fix the issues.",
                "",
                *silent_rules,
                "",
                f"## Task\n{self.task}",
                f"\n## Feedback from Previous Attempt (MUST address)\n{feedback}",
            ]
        else:
            lines = [
                "You are a delegated sub-agent with full tool access.",
                "Use your tools to complete the following task. Do not describe what to do — do it.",
                "",
                *silent_rules,
                "",
                f"## Task\n{self.task}",
            ]
        if self.deliverables:
            lines.append("\n## Required Deliverables")
            lines.extend(f"- {d}" for d in self.deliverables)
        if self.constraints:
            lines.append("\n## Constraints (MUST follow)")
            lines.extend(f"- {c}" for c in self.constraints)
        if self.out_of_scope:
            lines.append("\n## Out of Scope (do NOT do these)")
            lines.extend(f"- {s}" for s in self.out_of_scope)
        if self.context_files:
            lines.append("\n## Context Files (read these first)")
            lines.extend(f"- {f}" for f in self.context_files)
        if child_results:
            lines.append("\n## Child Task Results (use these to synthesize your response)")
            for cr in child_results:
                lines.append(f"\n### {cr['id']}: {cr['task']}")
                lines.append(cr["result"])
        lines.append(
            "\nWhen done, clearly state what you produced and where."
            " Be specific about deliverable locations."
        )
        return "\n".join(lines)


# ---------------------------------------------------------------------------
# DelegatedJob — a Brainfile v2 task file with embedded contract
# ---------------------------------------------------------------------------


@dataclass
class DelegatedJob:
    """
    A delegated task stored as a Brainfile v2 markdown file.

    Active tasks: brainfile/board/{id}.md
    Completed tasks: brainfile/logs/{id}.md

    Schema: https://brainfile.md/v2/task.json
    """

    id: str
    contract: DelegationContract
    status: JobStatus
    chat_id: str  # Which session spawned this
    created_at: datetime
    updated_at: datetime
    output_format: str = "plain"  # Notification/output format for this run context
    bot_id: str | None = None  # Bot alias that spawned this session (for restart recovery)
    model: str | None = None  # Current model in the fallback route
    model_chain: list[str] = field(default_factory=list)  # Ordered fallback chain
    model_index: int = 0  # Current index into model_chain
    subagent_name: str | None = None  # Optional named subagent profile used
    result_summary: str | None = None  # Short summary of completed work
    error: str | None = None  # Error message if failed
    feedback: str | None = None  # Rework guidance after failed validation
    attempts: int = 0  # How many times this job has run
    max_attempts: int = 3  # Max rework retries before giving up
    depends_on: list[str] = field(default_factory=list)  # Task IDs this job waits for
    parent_id: str | None = None  # Parent task ID (brainfile parentId)
    children: list[str] = field(default_factory=list)  # Child task IDs (brainfile children)
    # Metrics (auto-populated on state transitions)
    picked_up_at: datetime | None = None  # First transition to IN_PROGRESS
    delivered_at: datetime | None = None  # Last transition to DELIVERED
    completed_at: datetime | None = None  # When moved to logs/
    duration: float | None = None  # Seconds from pickup to delivery
    # Append-only log entries for markdown body
    log_entries: list[str] = field(default_factory=list)

    @classmethod
    def create(
        cls,
        *,
        contract: DelegationContract,
        chat_id: str,
        model: str | None = None,
        model_chain: list[str] | None = None,
        subagent_name: str | None = None,
        output_format: str = "plain",
        bot_id: str | None = None,
    ) -> "DelegatedJob":
        """Factory: create a new READY task with a sequential ID."""
        now = datetime.now(UTC)
        task_id = _bf_next_id(str(BOARD_DIR), str(LOGS_DIR))

        resolved_chain: list[str] = []
        seen: set[str] = set()
        for candidate in [*(model_chain or []), model, contract.model]:
            if not candidate:
                continue
            normalized = str(candidate).strip()
            if not normalized or normalized in seen:
                continue
            seen.add(normalized)
            resolved_chain.append(normalized)

        resolved_model = resolved_chain[0] if resolved_chain else (model or contract.model)

        job = cls(
            id=task_id,
            contract=contract,
            status=JobStatus.READY,
            chat_id=chat_id,
            output_format=output_format,
            bot_id=bot_id,
            created_at=now,
            updated_at=now,
            model=resolved_model,
            model_chain=resolved_chain,
            model_index=0,
            subagent_name=subagent_name,
        )
        job.log_entries.append(f"{now.isoformat()}: Created")
        return job

    def transition(self, new_status: JobStatus) -> None:
        """Update status, bump updated_at, and auto-populate metrics."""
        now = datetime.now(UTC)
        self.status = new_status
        self.updated_at = now
        if new_status == JobStatus.IN_PROGRESS and self.picked_up_at is None:
            self.picked_up_at = now
            self.log_entries.append(f"{now.isoformat()}: Picked up")
        elif new_status == JobStatus.DELIVERED:
            self.delivered_at = now
            if self.picked_up_at is not None:
                self.duration = (now - self.picked_up_at).total_seconds()
            self.log_entries.append(f"{now.isoformat()}: Delivered")
        elif new_status == JobStatus.DONE:
            self.completed_at = now
            self.log_entries.append(f"{now.isoformat()}: Completed")
        elif new_status == JobStatus.REWORKING:
            self.log_entries.append(
                f"{now.isoformat()}: Reworking (attempt {self.attempts}/{self.max_attempts})"
            )
        elif new_status == JobStatus.FAILED:
            self.log_entries.append(f"{now.isoformat()}: Failed")
        elif new_status == JobStatus.CANCELLED:
            self.completed_at = now
            self.log_entries.append(f"{now.isoformat()}: Cancelled")

    def advance_model(self) -> bool:
        """Switch to the next model in model_chain. Returns True if changed."""
        if not self.model_chain:
            return False

        idx = self.model_index
        if idx < 0:
            idx = 0
        if idx >= len(self.model_chain):
            idx = len(self.model_chain) - 1

        if self.model and self.model in self.model_chain:
            idx = self.model_chain.index(self.model)

        next_index = idx + 1
        if next_index >= len(self.model_chain):
            return False

        self.model_index = next_index
        self.model = self.model_chain[next_index]
        return True

    def _title(self) -> str:
        """Short title from task description (first line, max 80 chars)."""
        first_line = self.contract.task.split("\n", 1)[0].strip()
        return first_line[:77] + "..." if len(first_line) > 80 else first_line

    def to_frontmatter(self) -> dict[str, Any]:
        """Build YAML frontmatter dict per Brainfile v2 task schema."""
        fm: dict[str, Any] = {
            "id": self.id,
            "type": "task",
            "title": self._title(),
        }
        # Active tasks need a column; completed tasks don't
        column = _STATUS_TO_COLUMN.get(self.status)
        if column:
            fm["column"] = column
        fm["assignee"] = self.subagent_name or "main"
        fm["createdAt"] = self.created_at.isoformat()
        fm["updatedAt"] = self.updated_at.isoformat()
        if self.completed_at:
            fm["completedAt"] = self.completed_at.isoformat()
        if self.contract.context_files:
            fm["relatedFiles"] = self.contract.context_files
        if self.depends_on:
            fm["blockedBy"] = list(self.depends_on)
        if self.parent_id:
            fm["parentId"] = self.parent_id
        if self.children:
            fm["children"] = list(self.children)

        # Contract block — Brainfile v2 contract schema
        contract: dict[str, Any] = {"status": self.status.value}
        if self.contract.deliverables:
            contract["deliverables"] = [{"path": d} for d in self.contract.deliverables]
        if self.contract.validation_cmds:
            contract["validation"] = {"commands": self.contract.validation_cmds}
        if self.contract.constraints:
            contract["constraints"] = self.contract.constraints
        if self.contract.out_of_scope:
            contract["outOfScope"] = self.contract.out_of_scope
        if self.feedback:
            contract["feedback"] = self.feedback
        contract["metrics"] = {
            "pickedUpAt": self.picked_up_at.isoformat() if self.picked_up_at else None,
            "deliveredAt": self.delivered_at.isoformat() if self.delivered_at else None,
            "duration": int(self.duration) if self.duration is not None else None,
            "reworkCount": max(0, self.attempts - 1),
        }
        fm["contract"] = contract

        # Otto extensions (additionalProperties per schema)
        fm["x-otto"] = {
            "chatId": self.chat_id,
            "outputFormat": self.output_format,
            "botId": self.bot_id,
            "model": self.model,
            "modelChain": self.model_chain,
            "modelIndex": self.model_index,
            "subagent": self.subagent_name or "main",
            "attempts": self.attempts,
            "maxAttempts": self.max_attempts,
            "timeout": self.contract.timeout,
        }
        return fm

    def to_markdown(self) -> str:
        """Serialize to full Brainfile v2 task markdown file."""
        fm = self.to_frontmatter()
        task = BfTask.model_validate(fm)
        body = self._build_body()
        return _bf_serialize(task, body)

    def _build_body(self) -> str:
        """Build the markdown body sections."""
        parts: list[str] = []
        parts.append(f"## Description\n\n{self.contract.task}\n")
        if self.result_summary:
            parts.append(f"\n## Result\n\n{self.result_summary}\n")
        if self.error:
            parts.append(f"\n## Error\n\n{self.error}\n")
        if self.log_entries:
            parts.append("\n## Log\n")
            for entry in self.log_entries:
                parts.append(f"- {entry}\n")
        return "".join(parts)

    def to_dict(self) -> dict[str, Any]:
        """Dict for API consumers — Brainfile frontmatter + convenience fields."""
        fm = self.to_frontmatter()
        # Convenience fields for tools/commands (not in frontmatter)
        fm["description"] = self.contract.task
        fm["result_summary"] = self.result_summary
        fm["error"] = self.error
        fm["feedback"] = self.feedback
        fm["logEntries"] = list(self.log_entries)
        # Top-level status alias (canonical is contract.status)
        fm["status"] = self.status.value
        return fm

    def short_desc(self) -> str:
        """One-line human summary for user-facing messages."""
        task_preview = self.contract.task[:60]
        if len(self.contract.task) > 60:
            task_preview += "..."
        return f"[{self.id}] {self.status.upper()} — {task_preview}"


# ---------------------------------------------------------------------------
# Deserializer — markdown -> DelegatedJob
# ---------------------------------------------------------------------------


def _job_from_markdown(content: str) -> DelegatedJob | None:
    """Reconstruct a DelegatedJob from a task markdown file. Returns None on bad data."""
    try:
        doc = _bf_parse(content)
        if not doc:
            return None
        fm = doc.task.model_dump(by_alias=True)
        body = doc.body
        contract_data = _as_dict(fm.get("contract"))
        otto_ext = _as_dict(fm.get("x-otto"))

        # Task text lives in the markdown body
        task_text = _bf_extract_description(body) or fm.get("title", "")

        # Deliverables: extract paths from structured deliverable objects
        deliverables_raw = _as_list(contract_data.get("deliverables"))
        deliverables = [
            d.get("path", "") if isinstance(d, dict) else str(d) for d in deliverables_raw
        ]

        raw_model_chain = otto_ext.get("modelChain", [])
        model_chain = [
            str(value).strip()
            for value in raw_model_chain
            if isinstance(value, str) and str(value).strip()
        ]
        if not model_chain and isinstance(otto_ext.get("model"), str):
            model_chain = [str(otto_ext.get("model")).strip()]

        contract = DelegationContract(
            task=task_text,
            deliverables=deliverables,
            constraints=_as_list(contract_data.get("constraints")),
            out_of_scope=_as_list(contract_data.get("outOfScope")),
            validation_cmds=_as_list(_as_dict(contract_data.get("validation")).get("commands")),
            context_files=_as_list(fm.get("relatedFiles")),
            model=otto_ext.get("model"),
            timeout=otto_ext.get("timeout"),
        )

        raw_subagent = otto_ext.get("subagent")
        if not isinstance(raw_subagent, str) or raw_subagent.strip() in {"", "main"}:
            subagent_name = None
        else:
            subagent_name = raw_subagent.strip()

        metrics = _as_dict(contract_data.get("metrics"))

        return DelegatedJob(
            id=fm["id"],
            contract=contract,
            status=JobStatus(contract_data.get("status", "ready")),
            chat_id=otto_ext.get("chatId", "unknown") or "unknown",
            output_format=str(otto_ext.get("outputFormat", "plain") or "plain"),
            bot_id=(str(otto_ext.get("botId")).strip() if otto_ext.get("botId") else None),
            created_at=_parse_dt(fm.get("createdAt")) or datetime.now(UTC),
            updated_at=_parse_dt(fm.get("updatedAt")) or datetime.now(UTC),
            model=otto_ext.get("model"),
            model_chain=model_chain,
            model_index=int(otto_ext.get("modelIndex", 0) or 0),
            subagent_name=subagent_name,
            result_summary=_extract_section(body, "Result"),
            error=_extract_section(body, "Error"),
            feedback=contract_data.get("feedback"),
            attempts=int(otto_ext.get("attempts", 0) or 0),
            max_attempts=int(otto_ext.get("maxAttempts", 3) or 3),
            depends_on=_as_list(fm.get("blockedBy")),
            parent_id=fm.get("parentId"),
            children=_as_list(fm.get("children")),
            picked_up_at=_parse_dt(metrics.get("pickedUpAt")),
            delivered_at=_parse_dt(metrics.get("deliveredAt")),
            completed_at=_parse_dt(fm.get("completedAt")),
            duration=metrics.get("duration"),
            log_entries=_bf_extract_log_entries(body),
        )
    except (KeyError, ValueError, TypeError) as exc:
        _log.warning("Could not parse task file: %s", exc)
        return None


# ---------------------------------------------------------------------------
# JobRegistry — in-memory store with markdown persistence + event log
# ---------------------------------------------------------------------------


_TERMINAL_STATUSES = frozenset({JobStatus.DONE, JobStatus.CANCELLED, JobStatus.FAILED})


class JobRegistry:
    """
    In-memory registry of DelegatedJobs with Brainfile v2 markdown persistence.

    Active tasks are stored as .md files in brainfile/board/. Terminal tasks
    (DONE, CANCELLED) are moved to brainfile/logs/. All state changes are appended to
    events.jsonl for auditability and startup state restore.

    The Orchestrator is responsible for concurrency control (asyncio.Lock);
    this class does not manage locks itself.
    """

    def __init__(self) -> None:
        _ensure_dirs()
        _ensure_board_config()
        self._jobs: dict[str, DelegatedJob] = {}

    def add(self, job: DelegatedJob) -> None:
        """Register a new job, write task .md file, and log 'created' event."""
        self._jobs[job.id] = job
        self._write_task(job)
        self._append_event("created", job)
        _log.debug("Job %s registered (status=%s)", job.id, job.status)

    def get(self, job_id: str) -> DelegatedJob | None:
        """Look up a job by ID. Returns None if not found."""
        return self._jobs.get(job_id)

    def update(self, job: DelegatedJob, event_type: str = "updated") -> None:
        """Persist a status change or result update to memory, disk, and event log."""
        self._jobs[job.id] = job
        self._write_task(job)
        # Move task file to brainfile/logs/ on terminal states
        if job.status in (JobStatus.DONE, JobStatus.CANCELLED):
            self._archive_task(job)
        self._append_event(event_type, job)
        _log.debug("Job %s event=%s status=%s", job.id, event_type, job.status)

    def remove(self, job_id: str) -> bool:
        """Remove a job record. Returns True if the job existed."""
        if job_id in self._jobs:
            job = self._jobs.pop(job_id)
            self._append_event("removed", job)
            return True
        return False

    def all_jobs(self) -> list[DelegatedJob]:
        """Return all jobs regardless of status."""
        return list(self._jobs.values())

    def by_status(self, *statuses: JobStatus) -> list[DelegatedJob]:
        """Filter jobs by one or more statuses."""
        status_set = set(statuses)
        return [j for j in self._jobs.values() if j.status in status_set]

    def running_count(self) -> int:
        """Count actively running (in_progress) jobs."""
        return sum(1 for j in self._jobs.values() if j.status == JobStatus.IN_PROGRESS)

    def deps_satisfied(self, job: DelegatedJob) -> bool:
        """Return True if all blockedBy (depends_on) jobs are DONE."""
        if not job.depends_on:
            return True
        for dep_id in job.depends_on:
            dep = self._jobs.get(dep_id)
            if dep is None:
                # Dep may have been evicted or archived — check logs/
                if self._is_archived_done(dep_id):
                    continue
                return False
            if dep.status != JobStatus.DONE:
                return False
        return True

    def deps_failed(self, job: DelegatedJob) -> str | None:
        """Return the ID of the first failed/cancelled dependency, or None."""
        for dep_id in job.depends_on:
            dep = self._jobs.get(dep_id)
            if dep is not None and dep.status in (JobStatus.FAILED, JobStatus.CANCELLED):
                return dep_id
            # Dep missing from registry and not archived as DONE → treat as failed
            if dep is None and not self._is_archived_done(dep_id):
                return dep_id
        return None

    def _is_archived_done(self, dep_id: str) -> bool:
        """Check if a task was archived as DONE in logs/."""
        job = self.load_job_from_logs(dep_id)
        return job is not None and job.status == JobStatus.DONE

    def pending_with_deps(self) -> list[DelegatedJob]:
        """Return READY jobs that have blockedBy (depends_on) set."""
        return [j for j in self._jobs.values() if j.status == JobStatus.READY and j.depends_on]

    def children_of(self, parent_id: str) -> list[DelegatedJob]:
        """Return all jobs whose parent_id matches the given ID."""
        return [j for j in self._jobs.values() if j.parent_id == parent_id]

    def children_all_done(self, parent_id: str) -> bool:
        """Return True if every child of the given parent is DONE."""
        kids = self.children_of(parent_id)
        return len(kids) > 0 and all(k.status == JobStatus.DONE for k in kids)

    def is_child(self, job: DelegatedJob) -> bool:
        """Return True if this job has a parent (i.e. is a child task)."""
        return job.parent_id is not None

    def cleanup_terminal(self) -> list[str]:
        """Remove terminal jobs that no pending job depends on.

        Returns the list of removed job IDs (empty if none removed).
        """
        needed: set[str] = set()
        for job in self._jobs.values():
            if job.status not in _TERMINAL_STATUSES:
                needed.update(job.depends_on)
                if job.parent_id:
                    needed.add(job.parent_id)
                needed.update(job.children)
        to_remove = [
            jid
            for jid, job in self._jobs.items()
            if job.status in _TERMINAL_STATUSES and jid not in needed
        ]
        for jid in to_remove:
            del self._jobs[jid]
        if to_remove:
            self.rotate_events()
        return to_remove

    def archive_stale_failed(self, max_age_hours: int = 24) -> int:
        """Archive FAILED jobs older than max_age_hours from board/ into logs/."""
        cutoff = datetime.now(UTC) - timedelta(hours=max_age_hours)
        stale_ids: list[str] = []
        for jid, job in self._jobs.items():
            if job.status != JobStatus.FAILED:
                continue
            updated_at = _parse_dt(job.updated_at)
            if updated_at is not None and updated_at <= cutoff:
                stale_ids.append(jid)
        for jid in stale_ids:
            job = self._jobs.pop(jid)
            self._archive_task(job)
            self._append_event("archived_stale_failed", job)
        return len(stale_ids)

    def rotate_events(self, max_lines: int = 10_000) -> int:
        """Trim events.jsonl to the most recent max_lines entries."""
        if max_lines < 0:
            max_lines = 0
        if not EVENTS_FILE.exists():
            return 0
        try:
            lines = EVENTS_FILE.read_text(encoding="utf-8").splitlines()
        except OSError as exc:
            _log.warning("Could not read event log for rotation: %s", exc)
            return 0

        kept = lines[-max_lines:] if max_lines else []
        trimmed = len(lines) - len(kept)
        if trimmed <= 0:
            return 0

        tmp_file = EVENTS_FILE.parent / ".events.jsonl.tmp"
        try:
            tmp_file.write_text("".join(f"{line}\n" for line in kept), encoding="utf-8")
            os.replace(tmp_file, EVENTS_FILE)
        except OSError as exc:
            _log.warning("Could not rotate event log: %s", exc)
            try:
                if tmp_file.exists():
                    tmp_file.unlink()
            except OSError:
                pass
            return 0
        return trimmed

    def load_job_from_logs(self, job_id: str) -> DelegatedJob | None:
        """Load a single archived job from logs/ without modifying the registry."""
        path = LOGS_DIR / f"{job_id}.md"
        if not path.exists():
            return None
        try:
            content = path.read_text(encoding="utf-8")
        except OSError as exc:
            _log.warning("Could not read archived task %s: %s", path, exc)
            return None
        return _job_from_markdown(content)

    def _write_task(self, job: DelegatedJob) -> None:
        """Write or update the task markdown file in brainfile/board/."""
        path = BOARD_DIR / f"{job.id}.md"
        try:
            path.write_text(job.to_markdown(), encoding="utf-8")
        except OSError as exc:
            _log.warning("Could not write task file %s: %s", path, exc)

    def _archive_task(self, job: DelegatedJob) -> None:
        """Move the task file from brainfile/board/ to brainfile/logs/."""
        src = BOARD_DIR / f"{job.id}.md"
        dst = LOGS_DIR / f"{job.id}.md"
        try:
            if src.exists():
                # Write final state to brainfile/logs/ then remove from board/
                dst.write_text(job.to_markdown(), encoding="utf-8")
                src.unlink()
                _log.debug("Archived task %s -> %s", src, dst)
        except OSError as exc:
            _log.warning("Could not archive task %s: %s", src, exc)

    def load_from_board(self) -> int:
        """Reconstruct registry from .md task files in brainfile/board/.

        Runs on every startup to restore persisted job state. Returns the number
        of jobs loaded. IN_PROGRESS jobs are transitioned to FAILED — they were
        interrupted by the restart and must be retried. Archived jobs in logs/
        are history and not reloaded.
        """
        loaded = 0
        for directory in (BOARD_DIR,):
            if not directory.exists():
                continue
            for path in directory.glob("task-*.md"):
                try:
                    content = path.read_text(encoding="utf-8")
                    job = _job_from_markdown(content)
                    if job is None:
                        continue
                    # Jobs mid-execution at shutdown are interrupted — mark failed.
                    if job.status in (JobStatus.IN_PROGRESS, JobStatus.REWORKING):
                        job.transition(JobStatus.FAILED)
                        job.error = "Otto restarted — job was interrupted"
                        job.feedback = (
                            "Previous attempt was interrupted by an Otto restart. "
                            "Re-run from scratch."
                        )
                        self._write_task(job)
                        _log.warning(
                            "Startup: job %s was in-progress at shutdown, marked failed", job.id
                        )
                    self._jobs[job.id] = job
                    loaded += 1
                except (ValueError, OSError) as exc:
                    _log.warning("Could not load task %s: %s", path, exc)
        if loaded:
            _log.info("Startup: restored %d tasks from board", loaded)
        return loaded

    def _append_event(self, event_type: str, job: DelegatedJob) -> None:
        """Append a structured event to the append-only events log."""
        event = {
            "ts": datetime.now(UTC).isoformat(),
            "event": event_type,
            "job_id": job.id,
            "chat_id": job.chat_id,
            "status": job.status,
        }
        try:
            with EVENTS_FILE.open("a", encoding="utf-8") as f:
                f.write(json.dumps(event) + "\n")
        except OSError as exc:
            _log.warning("Could not write event log: %s", exc)
