"""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.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
    }


@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."""
    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_ready(
    ctx: Context, priority: str | None = None, limit: int = 10,
    verbose: bool = False, check_conflicts: bool = False,
) -> list[dict] | dict:
    """Return tasks with no open blockers, sorted by priority (p0 first).

    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.
    """
    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")
        claimed_files: dict[str, list[str]] = {}  # file -> [task_ids]
        for ct in claimed:
            for f in (ct.context.get("files") or []) if isinstance(ct.context, dict) else []:
                claimed_files.setdefault(f, []).append(ct.id)
        for d in dumps:
            ctx_data = d.get("context") or {}
            files = ctx_data.get("files") or [] if isinstance(ctx_data, dict) else []
            overlaps = {f: claimed_files[f] for f in files if f in claimed_files}
            if overlaps:
                d["file_conflicts"] = overlaps
    result = dumps if verbose else [_concise_task(d) for d in dumps]
    if not result:
        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 {"tasks": [], "hint": f"No ready tasks. {idea_count} idea(s) in backlog — use loom_status(filter='idea') to review."}
    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. Optional ttl_seconds overrides config default."""
    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)
    return task.model_dump(mode="json")


@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.

    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.
    """
    app = _ctx(ctx)
    if not skip_verify:
        task_pre = await cache.get_task(app.redis, app.pool, app.project_id, task_id)
        if task_pre.verify_command:
            import asyncio
            try:
                proc = await asyncio.create_subprocess_shell(
                    task_pre.verify_command,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
                stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
                if proc.returncode != 0:
                    return {
                        "error": "verify_command failed",
                        "task_id": task_id,
                        "returncode": proc.returncode,
                        "stdout": stdout.decode(errors="replace")[-500:],
                        "stderr": stderr.decode(errors="replace")[-500:],
                    }
            except asyncio.TimeoutError:
                return {"error": "verify_command timed out (120s)", "task_id": task_id}
    result = await store.complete_task(app.pool, task_id, output, branch_name=branch_name)
    task, merge_task = result if isinstance(result, tuple) else (result, 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_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})
    resp = task.model_dump(mode="json")
    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)
        resp["merge_task"] = merge_task.model_dump(mode="json")
    return resp


@mcp.tool()
async def loom_fail(ctx: Context, task_id: str, reason: str) -> dict:
    """Mark task failed. Handles retry logic or dead letter, creates escalation."""
    app = _ctx(ctx)
    task = await store.fail_task(app.pool, task_id, reason)
    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)
    # Phase 3: retry or dead letter
    from loom.orchestration.retry import process_failed_task
    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."""
    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_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,
) -> 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.
    """
    app = _ctx(ctx)
    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.model_dump(mode="json")


@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:
        task = await cache.get_task(app.redis, app.pool, app.project_id, task_id)
        return task.model_dump(mode="json")
    if filter:
        valid = {"pending", "claimed", "done", "failed", "blocked", "epic", "idea"}
        if filter not in valid:
            return {"error": f"Invalid filter '{filter}'. Valid: {', '.join(sorted(valid))}"}
        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]
    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)
    return result


@mcp.tool()
async def loom_message(
    ctx: Context, to: str, message: str, thread_id: str | None = None
) -> dict:
    """Send a direct message to an agent or broadcast."""
    app = _ctx(ctx)
    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, "message": message},
    )
    return {"sent": True, "to": to}


@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. confirm=True returns proposal only.

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

    parent_epic_id: manually link all generated tasks/epics to an existing epic.

    force_leaf: convert childless epics to pending tasks so they can be
    claimed immediately. Recommended when decomposing an epic into work items.
    Auto-enabled when epic_id is provided.

    granularity: 'coarse' (fewer large tasks for solo work), 'fine' (many
    small tasks for multi-agent dispatch), or None for default.

    dependency_hints: {"after": ["task-id-1"], "before": ["task-id-2"]}
    to inject cross-epic ordering context. 'after' tasks must complete before
    this decomposition's tasks can start; 'before' tasks will be blocked until
    this decomposition's tasks complete.

    auto_fix_paths: when True (default), stale file paths in generated tasks
    are automatically corrected against the real project file tree.

    merge_file_affinity: when True (default), tasks with high file overlap
    are merged together to reduce fragmentation.
    """
    from loom.graph.project import get_project
    from loom.skills.decomposer import decompose
    app = _ctx(ctx)
    if not app.project_id:
        return {"error": "No active project. Create one with loom_create_project first."}
    if not app.config.skills.api_key:
        return {"error": "No API key configured. Set ANTHROPIC_API_KEY env var, or add skills.api_key to ~/.loom/config.yaml."}
    try:
        await get_project(app.pool, app.project_id)
    except Exception:
        return {"error": f"Project '{app.project_id}' not found. Create one with loom_create_project and loom_switch_project first."}

    # If epic_id provided, read the epic and use its title/context as the goal
    if epic_id:
        try:
            epic = await cache.get_task(app.redis, app.pool, app.project_id, epic_id)
        except Exception:
            return {"error": f"Epic '{epic_id}' not found."}
        if epic.status == TaskStatus.IDEA:
            epic_task = await store.promote_idea(app.pool, epic_id)
            await cache.sync_task(app.redis, epic_task)
            epic = epic_task
        elif epic.status != TaskStatus.EPIC:
            return {"error": f"Task '{epic_id}' is '{epic.status}', not an epic."}
        goal = goal or f"Decompose: {epic.title}"
        epic_ctx = epic.context.get("description", "") if isinstance(epic.context, dict) else ""
        if epic_ctx and not context:
            context = epic_ctx
        parent_epic_id = epic_id
        if not force_leaf:
            force_leaf = True  # Default to leaf tasks when decomposing an epic

        # Inject existing sibling tasks as context to prevent duplicate work items
        existing = await store.get_tasks_by_status(app.pool, app.project_id, "pending", limit=200)
        existing += await store.get_tasks_by_status(app.pool, app.project_id, "blocked", limit=200)
        existing += await store.get_tasks_by_status(app.pool, app.project_id, "claimed", limit=200)
        existing += await store.get_tasks_by_status(app.pool, app.project_id, "epic", limit=200)
        if existing:
            sibling_titles = [f"- {t.title}" for t in existing if t.id != epic_id]
            if sibling_titles:
                dedup_ctx = (
                    "\n\nEXISTING TASKS IN THIS PROJECT (do NOT create duplicates of these):\n"
                    + "\n".join(sibling_titles)
                )
                context = (context or "") + dedup_ctx

    if not goal:
        return {"error": "Provide a goal or epic_id to decompose."}

    # Inject granularity guidance into context
    if granularity:
        gran_map = {
            "coarse": "\n\nGRANULARITY: Create fewer, larger tasks (3-5 per epic). Each task may span multiple files and take a full work session. Suitable for a single agent working sequentially.",
            "fine": "\n\nGRANULARITY: Create many small, focused tasks (5-10 per epic). Each task should be one unit of work for a single subagent. Suitable for multi-agent parallel dispatch.",
        }
        if granularity in gran_map:
            context = (context or "") + gran_map[granularity]

    # Inject dependency hints into context
    if dependency_hints:
        hint_parts = []
        if dependency_hints.get("after"):
            after_ids = dependency_hints["after"]
            after_tasks = []
            for tid in after_ids:
                try:
                    t = await cache.get_task(app.redis, app.pool, app.project_id, tid)
                    after_tasks.append(f"- {t.title} ({t.id})")
                except Exception:
                    after_tasks.append(f"- {tid}")
            hint_parts.append(
                "ALL tasks in this decomposition MUST wait for these to complete first:\n"
                + "\n".join(after_tasks)
            )
        if dependency_hints.get("before"):
            before_ids = dependency_hints["before"]
            before_tasks = []
            for tid in before_ids:
                try:
                    t = await cache.get_task(app.redis, app.pool, app.project_id, tid)
                    before_tasks.append(f"- {t.title} ({t.id})")
                except Exception:
                    before_tasks.append(f"- {tid}")
            hint_parts.append(
                "These tasks are waiting on this decomposition to complete:\n"
                + "\n".join(before_tasks)
            )
        if hint_parts:
            context = (context or "") + "\n\nDEPENDENCY CONTEXT:\n" + "\n".join(hint_parts)

    # Collect parent epic's deps so children inherit cross-epic blocking
    parent_epic_deps: list[str] | None = None
    if epic_id:
        epic_obj = await cache.get_task(app.redis, app.pool, app.project_id, epic_id)
        if epic_obj.depends_on:
            parent_epic_deps = list(epic_obj.depends_on)
    # Also merge "after" hints into parent_epic_deps
    if dependency_hints and dependency_hints.get("after"):
        if parent_epic_deps is None:
            parent_epic_deps = []
        for tid in dependency_hints["after"]:
            if tid not in parent_epic_deps:
                parent_epic_deps.append(tid)

    from pathlib import Path as _Path
    result = await decompose(
        goal=goal, project_id=app.project_id, config=app.config.skills,
        pool=app.pool, redis=app.redis, context=context, confirm=confirm,
        project_dir=app.project_dir, depth=depth, enrich=enrich,
        scan_root=_Path(app.project_dir) if app.project_dir else None,
        optimize_parallelism=optimize_parallelism,
        parent_epic_id=parent_epic_id,
        force_leaf=force_leaf,
        parent_epic_deps=parent_epic_deps,
        auto_fix_paths=auto_fix_paths,
        merge_file_affinity=merge_file_affinity,
    )
    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."""
    app = _ctx(ctx)
    async with app.pool.acquire() as conn:
        tasks = await conn.fetch(
            "SELECT * FROM tasks WHERE project_id = $1::uuid", app.project_id
        )
        all_deps = await conn.fetch(
            """
            SELECT td.task_id, td.depends_on FROM task_deps td
            JOIN tasks t ON t.id = td.task_id
            WHERE t.project_id = $1::uuid
            """,
            app.project_id,
        )

    dep_map: dict[str, list[str]] = {}
    for d in all_deps:
        dep_map.setdefault(d["task_id"], []).append(d["depends_on"])

    task_list = []
    for t in tasks:
        task_list.append({
            "id": t["id"], "title": t["title"], "status": t["status"],
            "priority": t["priority"], "assignee": t["assignee"],
            "parent_id": t["parent_id"],
            "depends_on": dep_map.get(t["id"], []),
        })

    if format == "json":
        return {"tasks": task_list}

    if format == "mermaid":
        lines = ["graph TD"]
        for t in task_list:
            label = f"{t['id']}[{t['title']}]"
            lines.append(f"    {label}")
            for dep in t["depends_on"]:
                lines.append(f"    {dep} --> {t['id']}")
        return "\n".join(lines)

    # summary
    by_status: dict[str, int] = {}
    for t in task_list:
        by_status[t["status"]] = by_status.get(t["status"], 0) + 1
    total = len(task_list)
    parts = [f"{total} tasks total"]
    for s, c in sorted(by_status.items()):
        parts.append(f"  {s}: {c}")
    return "\n".join(parts)


@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)
    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)
    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.project_dir,
    )


@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)
    if action == "list":
        from loom.workflows.loader import discover_workflows
        wfs = discover_workflows(app.project_dir)
        return {"workflows": [
            {"name": w.name, "description": w.description, "inputs": w.inputs}
            for w in wfs.values()
        ]}
    if action == "status":
        if run_id:
            return await store.get_workflow_run(app.pool, run_id)
        runs = await store.list_workflow_runs(app.pool, app.project_id)
        return {"runs": runs}
    if action == "resume":
        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.project_dir,
        )
    # Default: start
    from loom.workflows.loader import load_workflow
    from loom.workflows.runner import run_workflow
    wf = load_workflow(workflow, app.project_dir)
    return await run_workflow(
        wf, inputs or {}, app.config.skills,
        app.pool, app.redis, app.project_id, app.project_dir,
    )


@mcp.tool()
async def loom_projects(ctx: Context) -> list[dict]:
    """List all projects with task count summaries."""
    from loom.graph.project import list_projects, get_project_status
    app = _ctx(ctx)
    projects = await list_projects(app.pool)
    result = []
    for p in projects:
        pid = str(p["id"])
        try:
            status = await get_project_status(app.pool, pid)
            result.append({
                "id": pid, "name": p["name"],
                "description": p.get("description", ""),
                "total": status.total, "done": status.done,
                "pending": status.pending, "failed": status.failed,
            })
        except Exception:
            result.append({"id": pid, "name": p["name"], "total": 0})
    return result


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


@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)
    async with app.pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT * FROM tasks
            WHERE project_id = $1::uuid AND dead_letter = TRUE
            ORDER BY updated_at DESC
            LIMIT $2
            """,
            app.project_id, limit,
        )
    task_ids = [row["id"] for row in rows]
    all_deps = await store._batch_load_deps(app.pool, task_ids)
    tasks = [Task.from_record(row, depends_on=all_deps.get(row["id"], [])) for row in rows]
    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)
    if dead_letter:
        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)}
    done_tasks, failures = await store.batch_done(app.pool, task_ids, output or {})
    for task in done_tasks:
        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}


@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)
    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
    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)
    done_ids = {t.id for t in done}
    plan = build_orchestration_plan(tasks, done_ids)

    if claim_wave and plan.get("waves"):
        if not agent_id:
            plan["claim_error"] = "agent_id required when claim_wave=True"
            return plan
        wave0 = plan["waves"][0]
        task_ids = []
        for group in wave0.get("agent_groups", []):
            task_ids.extend(group.get("task_ids", []))
        if task_ids:
            ttl = 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)
            plan["claimed"] = [t.id for t in claimed]
            if failures:
                plan["claim_failures"] = failures

    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.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.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.project_dir,
    )
    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.project_dir,
        )
        result["execution"] = outcome
    return result

