"""MCP tool implementations — thin coordinators, ≤15 lines each."""

from __future__ import annotations

from datetime import datetime, timedelta, timezone

from fastmcp import Context

from loom.bus.events import EventType
from loom.git import get_head_sha
from loom.observability.metrics import Metrics
from loom.bus.publisher import publish_escalation, publish_event, send_agent_message
from loom.graph import cache, deps, store
from loom.graph.project import get_project_status
from loom.graph.task import PRIORITY_SCORES, TASK_TYPE_MERGE, Task, TaskStatus
from loom.ids import task_id as gen_task_id
from loom.mcp.server import AppContext, mcp


def _ctx(ctx: Context) -> AppContext:
    return ctx.request_context.lifespan_context


# Fields to strip from task dicts in concise mode (large/verbose fields)
_VERBOSE_FIELDS = frozenset({
    "context", "output", "done_when",
    "claim_expires_at", "last_failed_at", "retry_after",
    "retry_count", "max_retries", "dead_letter", "dead_letter_reason",
    "created_at", "updated_at", "claimed_at", "done_at",
})


def _concise_task(task_dict: dict) -> dict:
    """Strip large fields from a task dict, keeping only essentials."""
    return {
        k: v for k, v in task_dict.items()
        if k not in _VERBOSE_FIELDS
    }


async def _maybe_inject_weft(app: AppContext, resp: dict) -> dict:
    """Inject Weft memories into response if Weft is configured."""
    if app.weft_pool and app.weft_embedder:
        from loom.integrations.weft import inject_weft_memories
        resp = await inject_weft_memories(
            resp, app.weft_pool, app.weft_embedder,
            budget_tokens=app.config.integrations.weft.budget_tokens,
            project_id=app.config.integrations.weft.project_id or None,
        )
    return resp


async def _empty_ready_hint(app: AppContext) -> str | None:
    """Generate hint text when no tasks are ready."""
    ideas = await store.list_ideas(app.pool, app.project_id, limit=1)
    if ideas:
        idea_count = len(await store.list_ideas(app.pool, app.project_id))
        return f"No ready tasks. {idea_count} idea(s) in backlog — use loom_status(filter='idea') to review."
    return None


async def _maybe_inject_inbox_hint(app: AppContext, resp: dict, agent_id: str) -> dict:
    """Add unread message hint to response if agent has unread messages."""
    try:
        count = await store.get_unread_count(app.pool, app.project_id, agent_id)
        if count > 0:
            resp["inbox_hint"] = (
                f"You have {count} unread message(s). "
                "Use loom_inbox(agent_id=...) to read them."
            )
    except Exception:
        pass  # messaging tables may not exist yet
    return resp


@mcp.tool()
async def loom_idea(ctx: Context, title: str, context: dict | None = None) -> dict:
    """Capture an idea for the backlog. Ideas are excluded from orchestration.

    Use for: feature ideas, potential improvements, experiments to try later.
    Ideas live in the backlog and won't appear in loom_ready. They can be
    promoted to tasks or epics later via loom_decompose(epic_id=...).

    Use loom_create for work you want done now. Use loom_issue for problems.
    """
    app = _ctx(ctx)
    task = await store.create_idea(app.pool, app.project_id, title, context)
    await cache.sync_task(app.redis, task)
    await publish_event(app.redis, app.project_id, EventType.IDEA_CREATED, task.id)
    await store.record_event(app.pool, app.project_id, EventType.IDEA_CREATED, task.id)
    return task.model_dump(mode="json")


@mcp.tool()
async def loom_issue(
    ctx: Context, title: str, context: dict | None = None, project: str | None = None,
) -> dict:
    """File an issue — a problem or bug that needs triage, not a planned task.

    Use loom_issue for: bugs discovered during work, technical debt noticed in
    passing, problems that need investigation before they become tasks.
    Issues are tracked separately and can be reviewed via loom_issues.

    Use loom_create instead for: planned work where you already know what to do.
    Use loom_idea instead for: nice-to-have improvements or future explorations.

    Optional project param targets another project by name/ID.
    """
    from loom.graph.project import find_project_by_name
    app = _ctx(ctx)
    target_id = app.project_id
    if project:
        target = await find_project_by_name(app.pool, project)
        target_id = str(target["id"])
    task = await store.create_issue(app.pool, target_id, title, context)
    await cache.sync_task(app.redis, task)
    await publish_event(app.redis, target_id, EventType.ISSUE_CREATED, task.id)
    await store.record_event(app.pool, target_id, EventType.ISSUE_CREATED, task.id)
    return task.model_dump(mode="json")


@mcp.tool()
async def loom_issues(ctx: Context, limit: int = 50) -> list[dict]:
    """List open issues for the current project."""
    app = _ctx(ctx)
    issues = await store.list_issues(app.pool, app.project_id, limit=limit)
    return [t.model_dump(mode="json") for t in issues]


@mcp.tool()
async def loom_resolve_issue(
    ctx: Context, task_id: str, resolution: str = "",
) -> dict:
    """Resolve an issue. Optional resolution note stored in context."""
    app = _ctx(ctx)
    task = await store.resolve_issue(app.pool, task_id, resolution)
    await cache.sync_task(app.redis, task)
    await publish_event(app.redis, app.project_id, EventType.ISSUE_RESOLVED, task.id)
    await store.record_event(app.pool, app.project_id, EventType.ISSUE_RESOLVED, task.id)
    return task.model_dump(mode="json")


@mcp.tool()
async def loom_ready(
    ctx: Context, priority: str | None = None, limit: int = 10,
    verbose: bool = False, check_conflicts: bool = False,
) -> dict:
    """Return tasks with no open blockers, sorted by priority (p0 first).

    Start here. This is your work queue — call it to see what needs doing next.
    Typical flow: loom_ready → pick a task → loom_claim → do work → loom_done.

    Default concise mode strips context/output for lower token usage.
    Set verbose=True for full task data.
    check_conflicts=True adds file_conflicts warnings for files shared with claimed tasks.
    """
    from loom.graph.query import annotate_conflicts, build_conflict_map
    app = _ctx(ctx)
    tasks = await cache.get_ready_tasks(app.redis, app.pool, app.project_id, limit=limit, priority=priority)
    dumps = [t.model_dump(mode="json") for t in tasks]
    if check_conflicts:
        claimed = await store.get_tasks_by_status(app.pool, app.project_id, "claimed")
        annotate_conflicts(dumps, build_conflict_map(claimed))
    task_list = dumps if verbose else [_concise_task(d) for d in dumps]
    result: dict = {"tasks": task_list, "scope": f"project '{app.project_id}'"}
    if not task_list:
        result["hint"] = await _empty_ready_hint(app)
    return result


@mcp.tool()
async def loom_claim(ctx: Context, task_id: str, agent_id: str, ttl_seconds: int | None = None) -> dict:
    """Atomically claim a task. Required before starting work.

    Claiming prevents other agents from picking up the same task. Always claim
    before doing any work — even in solo-agent mode, it tracks what's in progress
    and enables recovery if the session ends unexpectedly.

    agent_id: a short name for this agent (e.g. "warp", "agent-1").
    ttl_seconds: how long the claim lasts before expiring. Overrides config default.

    When Weft memory integration is enabled, relevant memories are injected
    into the response under context.weft_memories.
    """
    app = _ctx(ctx)
    ttl = ttl_seconds if ttl_seconds is not None else app.config.orchestration.claim_ttl_seconds
    task = await store.claim_task(app.pool, task_id, agent_id, ttl_seconds=ttl)
    await cache.sync_task(app.redis, task)
    await cache.remove_from_ready_queue(app.redis, app.project_id, task_id)
    await publish_event(app.redis, app.project_id, EventType.TASK_CLAIMED, task_id, agent_id)
    await store.record_event(app.pool, app.project_id, EventType.TASK_CLAIMED, task_id, agent_id)
    if app.auto_heartbeat:
        app.auto_heartbeat.register(task_id, ttl)
    resp = task.model_dump(mode="json")
    resp = await _maybe_inject_inbox_hint(app, resp, agent_id)
    return await _maybe_inject_weft(app, resp)


async def _create_task_core(
    app: AppContext, title: str, context: dict | None, depends_on: list[str] | None,
    priority: str, parent_id: str | None, done_when: str | None, verify_command: str | None,
) -> Task:
    """Create a task, sync cache, publish events."""
    dep_list = depends_on or []
    if dep_list:
        await deps.detect_cycle(app.pool, gen_task_id(), dep_list)
    status = await deps.compute_initial_status(app.pool, dep_list)
    task = Task(
        id=gen_task_id(), project_id=app.project_id, title=title,
        status=status, priority=priority, parent_id=parent_id,
        context=context or {}, done_when=done_when, depends_on=dep_list,
        verify_command=verify_command,
    )
    task = await store.create_task(app.pool, task)
    await cache.sync_task(app.redis, task)
    if status == TaskStatus.PENDING:
        await cache.add_to_ready_queue(app.redis, task)
    await publish_event(app.redis, app.project_id, EventType.TASK_CREATED, task.id)
    await store.record_event(app.pool, app.project_id, EventType.TASK_CREATED, task.id)
    Metrics().inc("tasks_created_total", labels={"project": app.project_id})
    return task


async def _auto_claim(app: AppContext, task: Task, agent_id: str) -> Task:
    """Auto-claim a newly created task for solo-agent work."""
    task = await store.claim_task(app.pool, task.id, agent_id, ttl_seconds=None)
    await cache.sync_task(app.redis, task)
    await cache.remove_from_ready_queue(app.redis, app.project_id, task.id)
    await publish_event(app.redis, app.project_id, EventType.TASK_CLAIMED, task.id, agent_id)
    await store.record_event(app.pool, app.project_id, EventType.TASK_CLAIMED, task.id, agent_id)
    return task


async def _maybe_suggest_deps(
    app: AppContext, resp: dict, title: str, context: dict | None,
) -> dict:
    """Inject dependency suggestions into response if available."""
    from loom.graph.suggestions import suggest_dependencies
    suggestions = await suggest_dependencies(app.pool, app.project_id, title, context=context)
    if suggestions:
        resp["suggested_dependencies"] = suggestions
    return resp


async def _sync_failed(app: AppContext, task: Task, reason: str) -> None:
    """Sync cache and publish events after task failure."""
    await cache.sync_task(app.redis, task)
    await cache.remove_from_ready_queue(app.redis, app.project_id, task.id)
    await publish_event(app.redis, app.project_id, EventType.TASK_FAILED, task.id)
    if app.config.orchestration.enable_escalation:
        await publish_escalation(app.redis, app.project_id, task.id, reason)
    await store.record_event(app.pool, app.project_id, EventType.TASK_FAILED, task.id)


async def _batch_dead_letter(app: AppContext, task_ids: list[str], reason: str) -> dict:
    """Dead-letter multiple tasks."""
    count = 0
    for tid in task_ids:
        try:
            await store.mark_dead_letter(app.pool, tid, reason or "bulk dead-letter")
            task = await store.get_task(app.pool, tid)
            await cache.sync_task(app.redis, task)
            await cache.remove_from_ready_queue(app.redis, app.project_id, tid)
            count += 1
        except Exception:
            pass
    return {"dead_lettered": count, "total": len(task_ids)}


async def _batch_complete(app: AppContext, task_ids: list[str], output: dict) -> dict:
    """Complete multiple tasks in batch."""
    done_tasks, failures = await store.batch_done(app.pool, task_ids, output)
    for task in done_tasks:
        if app.auto_heartbeat:
            app.auto_heartbeat.unregister(task.id)
        await cache.sync_task(app.redis, task)
        await cache.remove_from_ready_queue(app.redis, app.project_id, task.id)
        await publish_event(app.redis, app.project_id, EventType.TASK_DONE, task.id)
        await store.record_event(app.pool, app.project_id, EventType.TASK_DONE, task.id)
        await deps.check_and_unblock(app.pool, app.redis, task.id, app.project_id)
    Metrics().inc("tasks_completed_total", value=len(done_tasks), labels={"project": app.project_id})
    return {"done": [t.id for t in done_tasks], "failed": [f["task_id"] for f in failures], "errors": failures}


def _done_response(task: Task, merge_task) -> dict:
    """Build the response dict for loom_done."""
    resp = task.model_dump(mode="json")
    if merge_task:
        resp["merge_task"] = merge_task.model_dump(mode="json")
    resp["learnings_hint"] = "If you learned anything useful (gotchas, patterns, fixes), call weft_learn with a summary so future agents benefit."
    return resp


async def _complete_by_type(app, task_pre, task_id, output, branch_name):
    """Route completion to the right store function based on task type."""
    if task_pre.status == TaskStatus.IDEA:
        return await store.complete_idea(app.pool, task_id, output), None
    if task_pre.status == TaskStatus.EPIC:
        return await store.complete_epic(app.pool, task_id, output), None
    result = await store.complete_task(app.pool, task_id, output, branch_name=branch_name)
    return result if isinstance(result, tuple) else (result, None)


async def _sync_done(app, task_id, task, merge_task):
    """Sync cache, publish events, and unblock dependents after completion."""
    await cache.sync_task(app.redis, task)
    await cache.remove_from_ready_queue(app.redis, app.project_id, task_id)
    await publish_event(app.redis, app.project_id, EventType.TASK_DONE, task_id)
    await store.record_event(app.pool, app.project_id, EventType.TASK_DONE, task_id)
    await deps.check_and_unblock(app.pool, app.redis, task_id, app.project_id)
    Metrics().inc("tasks_completed_total", labels={"project": app.project_id})
    if merge_task:
        await cache.sync_task(app.redis, merge_task)
        await cache.add_to_ready_queue(app.redis, merge_task)
        await publish_event(app.redis, app.project_id, EventType.TASK_CREATED, merge_task.id)


@mcp.tool()
async def loom_done(
    ctx: Context, task_id: str, output: dict, branch_name: str | None = None,
    skip_verify: bool = False,
) -> dict:
    """Mark task complete. Runs dependency resolution to unblock dependents.

    Call this when the task's work is finished and verified. The output dict should
    summarize what was accomplished — downstream tasks and future agents use it
    for context. Good output includes:
      {"summary": "what changed", "files_modified": [...], "tests": "all passing"}
    Bad output: {} (tells no one anything).

    Works for regular tasks, ideas, and epics. Epics require all child tasks
    to be done first — if any are incomplete, returns an error.

    If the task has a verify_command, it runs first. Non-zero exit keeps
    the task claimed and returns the error. Pass skip_verify=True to bypass.

    Response includes a learnings_hint — if you learned anything useful
    during this task, call weft_learn with a summary.
    """
    from loom.orchestration.verify import run_verify_command
    app = _ctx(ctx)
    task_pre = await cache.get_task(app.redis, app.pool, app.project_id, task_id)
    if not skip_verify and task_pre.verify_command:
        err = await run_verify_command(task_pre.verify_command, task_id, timeout=app.config.orchestration.verify_command_timeout_seconds)
        if err:
            return err
    if app.auto_heartbeat:
        app.auto_heartbeat.unregister(task_id)
    sha = get_head_sha(app.local_project_dir) if app.local_project_dir else None
    if sha:
        output["commit_sha"] = sha
    task, merge_task = await _complete_by_type(app, task_pre, task_id, output, branch_name)
    await _sync_done(app, task_id, task, merge_task)
    return _done_response(task, merge_task)


@mcp.tool()
async def loom_fail(ctx: Context, task_id: str, reason: str) -> dict:
    """Mark task failed. Use when the work cannot be completed.

    Use loom_fail when: the approach is wrong, a dependency is missing, tests
    can't pass, or the task is fundamentally broken. Handles retry logic
    automatically — tasks retry up to max_retries before going to dead letter.

    Use loom_escalate instead when: you're blocked by something external
    (waiting on a human decision, unclear requirements, needs access/permissions)
    and the task could succeed once the blocker is resolved.
    """
    from loom.orchestration.retry import process_failed_task
    app = _ctx(ctx)
    if app.auto_heartbeat:
        app.auto_heartbeat.unregister(task_id)
    task = await store.fail_task(app.pool, task_id, reason)
    await _sync_failed(app, task, reason)
    task = await process_failed_task(app.pool, app.redis, task, app.config.orchestration, app.project_id)
    Metrics().inc("tasks_failed_total", labels={"project": app.project_id})
    return task.model_dump(mode="json")


@mcp.tool()
async def loom_escalate(ctx: Context, task_id: str, message: str) -> dict:
    """Flag task as blocked with escalation. Use when you need human input.

    Use loom_escalate when: requirements are unclear, you need a design decision,
    access is missing, or the task depends on something outside your control.
    The task stays visible as blocked until a human intervenes.

    Use loom_fail instead when: the task itself is broken — wrong approach,
    can't pass tests, missing dependencies that won't appear on their own.
    """
    app = _ctx(ctx)
    task = await store.set_task_status(app.pool, task_id, TaskStatus.BLOCKED)
    await store.create_escalation(app.pool, app.project_id, task_id, message)
    await cache.sync_task(app.redis, task)
    await cache.remove_from_ready_queue(app.redis, app.project_id, task_id)
    await publish_event(app.redis, app.project_id, EventType.TASK_BLOCKED, task_id)
    if app.config.orchestration.enable_escalation:
        await publish_escalation(app.redis, app.project_id, task_id, message)
    await store.record_event(app.pool, app.project_id, EventType.TASK_BLOCKED, task_id)
    return task.model_dump(mode="json")


@mcp.tool()
async def loom_escalations(
    ctx: Context, resolved: bool | None = False, limit: int = 50,
) -> dict:
    """List escalations for the current project. Triage open blockers here.

    Default shows open (unresolved) escalations. Set resolved=True for resolved,
    or resolved=null for all. Each entry includes the task title and current status.
    Use loom_resolve_escalation to close them once the blocker is addressed.
    """
    app = _ctx(ctx)
    rows = await store.list_escalations(app.pool, app.project_id, resolved=resolved, limit=limit)
    escalations = []
    for r in rows:
        escalations.append({
            "id": r["id"], "task_id": r["task_id"],
            "task_title": r["task_title"], "task_status": r["task_status"],
            "message": r["message"], "resolved": r["resolved"],
            "created_at": r["created_at"].isoformat() if r["created_at"] else None,
            "resolved_at": r["resolved_at"].isoformat() if r.get("resolved_at") else None,
        })
    return {"escalations": escalations, "count": len(escalations)}


@mcp.tool()
async def loom_resolve_escalation(
    ctx: Context, escalation_id: int, note: str | None = None,
) -> dict:
    """Resolve an open escalation. Use after the blocker has been addressed."""
    app = _ctx(ctx)
    row = await store.resolve_escalation(app.pool, escalation_id, note=note)
    await publish_event(
        app.redis, app.project_id, EventType.ESCALATION_RESOLVED,
        task_id=row["task_id"],
    )
    await store.record_event(
        app.pool, app.project_id, EventType.ESCALATION_RESOLVED, task_id=row["task_id"],
    )
    return {
        "resolved": True, "escalation_id": escalation_id,
        "task_id": row["task_id"],
    }


@mcp.tool()
async def loom_create(
    ctx: Context,
    title: str,
    context: dict | None = None,
    depends_on: list[str] | None = None,
    priority: str = "p1",
    parent_id: str | None = None,
    done_when: str | None = None,
    verify_command: str | None = None,
    suggest_deps: bool = False,
    agent_id: str | None = None,
) -> dict:
    """Create a new task with automatic status based on dependencies.

    verify_command: shell command to run on loom_done. If it exits non-zero,
    the task stays claimed and the error is returned.
    suggest_deps: when True and no depends_on provided, include suggested
    dependency tasks in the response based on title and file overlap.

    agent_id: provide your agent name to auto-claim the task on creation.
    Skips the separate loom_claim call and sets no claim TTL (no heartbeat
    needed). Use this for solo-agent work where you're creating a task and
    immediately starting on it. Only works when the task has no unmet
    dependencies (status=pending). Ignored for blocked tasks.

    When Weft is enabled and agent_id auto-claims, relevant memories are
    injected into the response under context.weft_memories."""
    app = _ctx(ctx)
    task = await _create_task_core(app, title, context, depends_on, priority, parent_id, done_when, verify_command)
    if agent_id and task.status == TaskStatus.PENDING:
        task = await _auto_claim(app, task, agent_id)
    resp = task.model_dump(mode="json")
    if agent_id and task.status == TaskStatus.CLAIMED:
        resp = await _maybe_inject_weft(app, resp)
    if suggest_deps and not (depends_on or []):
        resp = await _maybe_suggest_deps(app, resp, title, context)
    return resp


@mcp.tool()
async def loom_status(
    ctx: Context, task_id: str | None = None, verbose: bool = False,
    filter: str | None = None, limit: int = 50,
) -> dict | list[dict]:
    """Task detail (with task_id), filtered task list (with filter), or project overview.

    task_id: returns full detail for a single task.
    filter: returns tasks matching a status (pending, claimed, done, failed, blocked, epic).
    Without either, returns project overview with counts.
    """
    app = _ctx(ctx)
    if not app.project_id:
        return {"error": "No active project. Create one with loom_create_project and loom_switch_project first."}
    if task_id:
        return (await cache.get_task(app.redis, app.pool, app.project_id, task_id)).model_dump(mode="json")
    if filter:
        return await _status_filter(app, filter, verbose, limit)
    status = await get_project_status(app.pool, app.project_id)
    result = status.model_dump(mode="json")
    result["ready_count"] = await store.get_ready_count(app.pool, app.project_id)
    result["scope"] = f"Showing project '{result['project_name']}' only. Use loom_projects for cross-project view."
    return result


_VALID_FILTERS = {"pending", "claimed", "done", "failed", "blocked", "epic", "idea", "issue", "resolved"}


async def _status_filter(app: AppContext, filter: str, verbose: bool, limit: int) -> dict | list[dict]:
    """Return filtered task list for loom_status."""
    if filter not in _VALID_FILTERS:
        return {"error": f"Invalid filter '{filter}'. Valid: {', '.join(sorted(_VALID_FILTERS))}"}
    tasks = await store.get_tasks_by_status(app.pool, app.project_id, filter, limit=limit)
    dumps = [t.model_dump(mode="json") for t in tasks]
    return dumps if verbose else [_concise_task(d) for d in dumps]


@mcp.tool()
async def loom_message(
    ctx: Context, to: str, message: str,
    thread_id: str | None = None, from_agent: str | None = None,
) -> dict:
    """Send a direct message to an agent or broadcast."""
    app = _ctx(ctx)
    sender = from_agent or "unknown"
    msg = await store.create_message(
        app.pool, app.project_id, sender, to, message, thread_id,
    )
    await send_agent_message(app.redis, app.project_id, to, message, thread_id)
    await publish_event(
        app.redis, app.project_id, EventType.MESSAGE_SENT,
        payload={"to": to, "from_agent": sender, "message": message},
    )
    return msg.model_dump(mode="json")


@mcp.tool()
async def loom_inbox(
    ctx: Context, agent_id: str,
    unread_only: bool = False, thread_id: str | None = None,
    mark_read: bool = True, limit: int = 50,
) -> dict:
    """Fetch inbox for an agent. Auto-marks messages as read by default.

    Returns direct messages and broadcasts, unread first.
    Set mark_read=False to peek without marking read.
    Filter by unread_only=True or thread_id for narrower results.
    """
    app = _ctx(ctx)
    messages = await store.get_inbox(
        app.pool, app.project_id, agent_id,
        unread_only=unread_only, thread_id=thread_id, limit=limit,
    )
    if mark_read and messages:
        unread_ids = [m.id for m in messages if not m.is_read]
        if unread_ids:
            await store.mark_messages_read(app.pool, unread_ids, agent_id)
    return {
        "messages": [m.model_dump(mode="json") for m in messages],
        "count": len(messages),
    }


@mcp.tool()
async def loom_mark_read(
    ctx: Context, message_ids: list[str], agent_id: str,
) -> dict:
    """Mark specific message IDs as read."""
    app = _ctx(ctx)
    count = await store.mark_messages_read(app.pool, message_ids, agent_id)
    return {"marked": count, "agent_id": agent_id}


@mcp.tool()
async def loom_decompose(
    ctx: Context, goal: str = "", context: str | None = None,
    confirm: bool = True, depth: int = 3, enrich: bool = True,
    optimize_parallelism: bool = False,
    parent_epic_id: str | None = None,
    epic_id: str | None = None,
    force_leaf: bool = False,
    granularity: str | None = None,
    dependency_hints: dict | None = None,
    auto_fix_paths: bool = True,
    merge_file_affinity: bool = True,
) -> dict:
    """Decompose a goal into a task graph. Use for multi-step work that needs planning.

    Use loom_decompose when: the goal is too large for a single task, you need
    to parallelize work across agents, or you want AI-assisted planning. For a
    single well-defined task, use loom_create instead.

    confirm=True (default) returns a proposal with a proposal_id for review.
    After reviewing, call loom_confirm(proposal_id) to create the tasks without
    re-running the LLM. Or set confirm=False to skip review and create immediately.

    epic_id: decompose an existing epic into subtasks — reads its title/context
    as the goal and auto-links children. Preferred over manual parent_epic_id + goal.

    granularity: 'coarse' (3-5 larger tasks, solo agent) or 'fine' (5-10 small
    tasks, multi-agent dispatch). Default is balanced.

    auto_fix_paths: (default True) corrects stale file paths against the real project tree.
    merge_file_affinity: (default True) merges tasks with high file overlap to reduce conflicts.
    """
    from loom.mcp._decompose_prep import prepare_and_run_decompose
    app = _ctx(ctx)
    return await prepare_and_run_decompose(
        pool=app.pool, redis=app.redis, project_id=app.project_id,
        skills_config=app.config.skills, local_dir=app.local_project_dir,
        goal=goal, context=context, confirm=confirm, depth=depth, enrich=enrich,
        optimize_parallelism=optimize_parallelism, parent_epic_id=parent_epic_id,
        epic_id=epic_id, force_leaf=force_leaf, granularity=granularity,
        dependency_hints=dependency_hints, auto_fix_paths=auto_fix_paths,
        merge_file_affinity=merge_file_affinity,
        proposal_ttl_seconds=app.config.orchestration.proposal_ttl_seconds,
    )


@mcp.tool()
async def loom_confirm(ctx: Context, proposal_id: str) -> dict:
    """Confirm a decompose proposal, writing its tasks to the database.

    After reviewing a loom_decompose(confirm=True) proposal, call this with
    the proposal_id from the response to create the tasks. Avoids re-running
    the LLM. Proposals expire after 30 minutes.
    """
    from loom.skills.decomposer import confirm_proposal
    app = _ctx(ctx)
    try:
        result = await confirm_proposal(app.redis, app.pool, app.project_id, proposal_id)
    except ValueError as e:
        return {"error": str(e)}
    return result.model_dump(mode="json")


@mcp.tool()
async def loom_graph(ctx: Context, format: str = "json") -> dict | str:
    """Return the full task dependency graph for the current project."""
    from loom.graph.query import format_mermaid, format_summary, tasks_to_graph_list
    app = _ctx(ctx)
    all_tasks = await store.get_all_tasks_with_deps(app.pool, app.project_id)
    task_list = tasks_to_graph_list(all_tasks)
    if format == "json":
        return {"tasks": task_list}
    if format == "mermaid":
        return format_mermaid(task_list)
    return format_summary(task_list)


@mcp.tool()
async def loom_why_blocked(ctx: Context, task_id: str, max_depth: int = 10) -> dict:
    """Trace the dependency chain to show exactly what's blocking a task.

    Returns a tree of unsatisfied dependencies with status and assignee.
    Useful for debugging stuck task graphs.
    """
    from loom.graph.query import trace_blockers
    app = _ctx(ctx)
    return await trace_blockers(app.pool, task_id, max_depth=max_depth)


@mcp.tool()
async def loom_comment(ctx: Context, task_id: str, author: str, body: str) -> dict:
    """Add a comment to a task. Returns the created comment.

    Use for persistent notes, observations, or handoff context on a task.
    """
    app = _ctx(ctx)
    return await store.add_comment(app.pool, task_id, author, body)


@mcp.tool()
async def loom_comments(ctx: Context, task_id: str, limit: int = 50) -> list[dict]:
    """List comments on a task, newest first."""
    app = _ctx(ctx)
    return await store.get_comments(app.pool, task_id, limit=limit)


@mcp.tool()
async def loom_progress(ctx: Context) -> dict:
    """Show aggregated progress across all claimed tasks in the current project.

    Returns per-task progress (from heartbeats) and overall summary stats.
    """
    from loom.graph.query import aggregate_progress
    app = _ctx(ctx)
    return await aggregate_progress(app.pool, app.redis, app.project_id)


@mcp.tool()
async def loom_update(
    ctx: Context,
    task_id: str,
    title: str | None = None,
    context: dict | None = None,
    priority: str | None = None,
    done_when: str | None = None,
    depends_on: list[str] | None = None,
    status: str | None = None,
    verify_command: str | None = None,
) -> dict:
    """Update mutable task fields. depends_on replaces all current dependencies.

    status only supports epic→pending (to convert an epic into a claimable task).
    verify_command: shell command to run on loom_done (non-zero exit = stay claimed).
    """
    app = _ctx(ctx)
    task = await store.update_task(
        app.pool, task_id, title=title, context=context,
        priority=priority, done_when=done_when, depends_on=depends_on,
        status=status, verify_command=verify_command,
    )
    await cache.sync_task(app.redis, task)
    if task.status == TaskStatus.PENDING:
        await cache.add_to_ready_queue(app.redis, task)
    elif task.status == TaskStatus.BLOCKED:
        await cache.remove_from_ready_queue(app.redis, app.project_id, task_id)
    await publish_event(app.redis, app.project_id, EventType.TASK_UPDATED, task_id)
    await store.record_event(app.pool, app.project_id, EventType.TASK_UPDATED, task_id)
    return task.model_dump(mode="json")


@mcp.tool()
async def loom_heartbeat(
    ctx: Context, task_id: str,
    progress: str | None = None, percent: int | None = None,
) -> dict:
    """Extend claim TTL for an in-progress task. Optionally report progress."""
    app = _ctx(ctx)
    ttl = app.config.orchestration.claim_ttl_seconds
    expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
    task = await store.set_claim_expiry(app.pool, task_id, expires_at)
    await cache.sync_task(app.redis, task)
    if progress is not None or percent is not None:
        await cache.set_task_progress(
            app.redis, app.project_id, task_id, progress, percent,
            ttl=app.config.orchestration.progress_ttl_seconds,
        )
    remaining = (expires_at - datetime.now(timezone.utc)).total_seconds()
    return {"task_id": task_id, "expires_at": expires_at.isoformat(), "ttl_remaining": int(remaining)}


@mcp.tool()
async def loom_reset(ctx: Context, task_id: str, clear_output: bool = False) -> dict:
    """Reset a stuck/failed task back to pending."""
    app = _ctx(ctx)
    if app.auto_heartbeat:
        app.auto_heartbeat.unregister(task_id)
    task = await store.reset_task(app.pool, task_id, clear_output=clear_output)
    await cache.sync_task(app.redis, task)
    await cache.add_to_ready_queue(app.redis, task)
    await publish_event(app.redis, app.project_id, EventType.TASK_RESET, task_id)
    await store.record_event(app.pool, app.project_id, EventType.TASK_RESET, task_id)
    return task.model_dump(mode="json")


@mcp.tool()
async def loom_orchestrate_tick(ctx: Context) -> dict:
    """Run a single orchestrator sweep: expire claims, retry, escalate, close epics."""
    app = _ctx(ctx)
    from loom.orchestration.loop import orchestrator_tick
    return await orchestrator_tick(
        app.pool, app.redis, app.project_id, app.config, app.local_project_dir or "",
    )


@mcp.tool()
async def loom_workflow(
    ctx: Context,
    workflow: str = "",
    action: str = "start",
    run_id: str | None = None,
    inputs: dict | None = None,
) -> dict:
    """Workflow management: start | resume | status | list."""
    app = _ctx(ctx)
    handler = _WORKFLOW_ACTIONS.get(action, _workflow_start)
    return await handler(app, workflow, run_id, inputs)


async def _workflow_list(app: AppContext, workflow: str, run_id: str | None, inputs: dict | None) -> dict:
    from loom.workflows.loader import discover_workflows
    if not app.local_project_dir:
        return {"error": "Cannot discover workflows after project switch — filesystem not available for this project"}
    wfs = discover_workflows(app.local_project_dir)
    return {"workflows": [{"name": w.name, "description": w.description, "inputs": w.inputs} for w in wfs.values()]}


async def _workflow_status(app: AppContext, workflow: str, run_id: str | None, inputs: dict | None) -> dict:
    if run_id:
        return await store.get_workflow_run(app.pool, run_id)
    return {"runs": await store.list_workflow_runs(app.pool, app.project_id)}


async def _workflow_resume(app: AppContext, workflow: str, run_id: str | None, inputs: dict | None) -> dict:
    if not run_id:
        return {"error": "run_id required for resume"}
    from loom.workflows.runner import resume_workflow
    return await resume_workflow(run_id, app.pool, app.redis, app.config.skills, app.project_id, app.local_project_dir or "")


async def _workflow_start(app: AppContext, workflow: str, run_id: str | None, inputs: dict | None) -> dict:
    from loom.workflows.loader import load_workflow
    from loom.workflows.runner import run_workflow
    local_dir = app.local_project_dir or ""
    wf = load_workflow(workflow, local_dir)
    return await run_workflow(wf, inputs or {}, app.config.skills, app.pool, app.redis, app.project_id, local_dir)


_WORKFLOW_ACTIONS = {"list": _workflow_list, "status": _workflow_status, "resume": _workflow_resume}


@mcp.tool()
async def loom_projects(ctx: Context) -> list[dict]:
    """List all projects with task count summaries."""
    from loom.graph.query import list_projects_with_status
    app = _ctx(ctx)
    return await list_projects_with_status(app.pool)


@mcp.tool()
async def loom_create_project(
    ctx: Context, name: str, description: str = "", tags: list[str] | None = None,
) -> dict:
    """Create a new project with optional tags."""
    from loom.graph.project import create_project
    app = _ctx(ctx)
    project = await create_project(app.pool, name, description, tags=tags)
    return {"id": str(project["id"]), "name": project["name"], "description": description, "tags": project.get("tags", [])}


@mcp.tool()
async def loom_switch_project(ctx: Context, project_id: str) -> dict:
    """Switch the active project context."""
    from loom.graph.project import get_project
    app = _ctx(ctx)
    project = await get_project(app.pool, project_id)
    app.project_id = project_id
    if app.redis:
        await cache.rebuild_cache(app.redis, app.pool, project_id)
    return {"switched_to": project_id, "name": project["name"]}


@mcp.tool()
async def loom_archive_project(ctx: Context, project_id: str) -> dict:
    """Archive a project (soft-delete)."""
    from loom.graph.project import archive_project
    app = _ctx(ctx)
    project = await archive_project(app.pool, project_id)
    if app.redis:
        await cache.clear_project_cache(app.redis, project_id)
    return {"archived": project_id, "name": project["name"]}


@mcp.tool()
async def loom_dead_letter(ctx: Context, limit: int = 10) -> list[dict]:
    """List tasks in the dead letter queue (permanently failed)."""
    app = _ctx(ctx)
    tasks = await store.get_dead_letter_tasks(app.pool, app.project_id, limit=limit)
    return [t.model_dump(mode="json") for t in tasks]


@mcp.tool()
async def loom_batch_done(
    ctx: Context, task_ids: list[str], output: dict | None = None,
    reason: str = "", dead_letter: bool = False,
) -> dict:
    """Mark multiple tasks as done or dead-lettered in one batch."""
    app = _ctx(ctx)
    out = dict(output or {})
    sha = get_head_sha(app.local_project_dir) if app.local_project_dir else None
    if sha:
        out["commit_sha"] = sha
    if dead_letter:
        return await _batch_dead_letter(app, task_ids, reason)
    return await _batch_complete(app, task_ids, out)


@mcp.tool()
async def loom_batch_claim(
    ctx: Context, task_ids: list[str], agent_id: str,
    ttl_seconds: int | None = None,
) -> dict:
    """Atomically claim multiple tasks. Returns claimed IDs and failures."""
    app = _ctx(ctx)
    ttl = ttl_seconds if ttl_seconds is not None else app.config.orchestration.claim_ttl_seconds
    claimed, failures = await store.batch_claim(app.pool, task_ids, agent_id, ttl_seconds=ttl)
    for task in claimed:
        await cache.sync_task(app.redis, task)
        await cache.remove_from_ready_queue(app.redis, app.project_id, task.id)
        await publish_event(app.redis, app.project_id, EventType.TASK_CLAIMED, task.id, agent_id)
        await store.record_event(app.pool, app.project_id, EventType.TASK_CLAIMED, task.id, agent_id)
        if app.auto_heartbeat:
            app.auto_heartbeat.register(task.id, ttl)
    return {"claimed": [t.id for t in claimed], "failed": [f["task_id"] for f in failures], "errors": failures}


@mcp.tool()
async def loom_orchestrate(
    ctx: Context, claim_wave: bool = False, agent_id: str | None = None,
) -> dict:
    """Plan execution waves based on dependency graph and file overlap.

    Returns waves of tasks that can run in parallel, with agent groups
    based on shared files to minimize merge conflicts.

    claim_wave=True auto-claims all wave-0 (immediately ready) tasks.
    Requires agent_id when claiming.
    """
    from loom.orchestration.waves import build_orchestration_plan, claim_wave_zero
    app = _ctx(ctx)
    tasks = await store.get_actionable_tasks(app.pool, app.project_id)
    done = await store.get_tasks_by_status(app.pool, app.project_id, "done", limit=1000)
    plan = build_orchestration_plan(tasks, {t.id for t in done})
    if claim_wave and plan.get("waves"):
        plan = await claim_wave_zero(
            plan, app.pool, app.redis, app.project_id, agent_id,
            app.config.orchestration.claim_ttl_seconds,
        )
    return plan


@mcp.tool()
async def loom_verify_paths(
    ctx: Context, task_ids: list[str] | None = None, auto_fix: bool = False,
) -> dict:
    """Pre-flight path check. Default: all ready tasks. Returns stale->corrected map."""
    app = _ctx(ctx)
    if task_ids:
        tasks = [await cache.get_task(app.redis, app.pool, app.project_id, tid) for tid in task_ids]
    else:
        tasks = await cache.get_ready_tasks(app.redis, app.pool, app.project_id, limit=100)
    from loom.pathcheck import apply_corrections, check_paths
    report = check_paths(tasks, app.local_project_dir or ".")
    if auto_fix and report.tasks_with_issues > 0:
        await apply_corrections(app.pool, app.redis, report)
    return report.model_dump(mode="json")


@mcp.tool()
async def loom_round_summary(ctx: Context, run_tests: bool = True) -> dict:
    """Post-round checklist: run tests, snapshot project status, report."""
    app = _ctx(ctx)
    from loom.roundcheck import build_round_summary
    return await build_round_summary(
        pool=app.pool, project_id=app.project_id,
        project_dir=app.local_project_dir or ".", run_tests=run_tests,
    )


@mcp.tool()
async def loom_recover(ctx: Context, execute: bool = False) -> dict:
    """Classify orphaned tasks and optionally recover them.

    Call after context compaction to reconstruct state. Default dry-run
    shows a recovery plan; set execute=True to apply it.
    """
    from loom.recovery import build_recovery_plan, execute_recovery_plan
    app = _ctx(ctx)
    plan = await build_recovery_plan(
        app.pool, app.redis, app.project_id, app.config, app.local_project_dir or "",
    )
    result = plan.to_dict()
    if execute and plan.total_recoverable > 0:
        outcome = await execute_recovery_plan(
            app.pool, app.redis, app.project_id, app.config, plan, app.local_project_dir or "",
        )
        result["execution"] = outcome
    return result

