"""Goals and coaching API endpoints."""

from __future__ import annotations

import time as _time_goals

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

router = APIRouter(prefix="/goals", tags=["goals"])


class GoalCreateRequest(BaseModel):
    title: str
    description: str
    goal_type: str = "goal"
    target_date: str = ""
    success_metric: str = ""
    budget: float = 0.0
    max_attempts: int = 20
    domain: str = "general"


class GoalStatusRequest(BaseModel):
    status: str


class GoalUpdateRequest(BaseModel):
    description: str | None = None
    target_date: str | None = None


class GoalRenameRequest(BaseModel):
    title: str


class GoalPursuitUpdateRequest(BaseModel):
    success_metric: str | None = None
    budget: float | None = None
    max_dispatches: int | None = None
    max_attempts: int | None = None


class TrustOverrideRequest(BaseModel):
    enabled: bool


class DomainUpdateRequest(BaseModel):
    domain: str


@router.get("")
async def list_goals(request: Request, status: str | None = None) -> list[dict]:
    """List all goals."""
    coach = request.app.state.coach
    return coach.list_goals(status_filter=status)


@router.post("")
async def create_goal(request: Request, body: GoalCreateRequest) -> dict:
    """Create a new goal."""
    coach = request.app.state.coach
    path = coach.create_goal(
        title=body.title,
        description=body.description,
        goal_type=body.goal_type,
        target_date=body.target_date,
        success_metric=body.success_metric,
        budget=body.budget,
        max_attempts=body.max_attempts,
        domain=body.domain,
    )
    return {"path": path, "status": "active"}


def _build_progress_summary(coach, task_svc, project_svc, llm_mode: str) -> dict:
    """Pure sync builder — runs the file-walking work off the event loop.

    Called from the route via asyncio.to_thread so dashboard polls don't
    queue every other request behind 5+ seconds of blocking file reads.
    """
    goals = coach.list_goals()
    tasks = task_svc.list_tasks()
    projects = project_svc.list_projects()

    active_goals = [g for g in goals if g.get("status") == "active"]
    achieved_goals = [g for g in goals if g.get("status") == "achieved"]

    pursuing = []
    for g in active_goals:
        try:
            ps = coach.get_pursuit_status(g["slug"])
            if ps.get("pursuit_status") == "pursuing":
                pursuing.append({
                    "slug": g["slug"],
                    "title": g["title"],
                    "budget": ps.get("budget", 0),
                    "budget_consumed": ps.get("budget_consumed", 0),
                    "dispatches": ps.get("dispatches", 0),
                    "max_dispatches": ps.get("max_dispatches", 50),
                    "success_metric": ps.get("success_metric", ""),
                })
        except Exception:
            pass

    tasks_done = len([t for t in tasks if t.get("status") == "done"])
    tasks_active = len([t for t in tasks if t.get("status") in ("in_progress", "delegated")])
    tasks_blocked = len([t for t in tasks if t.get("status") == "blocked"])

    stalls = []
    try:
        stalls = coach.detect_stalls()
    except Exception:
        pass

    halted = []
    try:
        halted = coach.list_halted_goals(max_age_hours=6)
    except Exception:
        pass

    return {
        "goals_total": len(goals),
        "goals_active": len(active_goals),
        "goals_achieved": len(achieved_goals),
        "goals_pursuing": pursuing,
        "goals_stalled": [{"slug": s["slug"], "title": s["goal"], "days": s["days_inactive"]} for s in stalls],
        "goals_halted": halted,
        "tasks_total": len(tasks),
        "tasks_done": tasks_done,
        "tasks_active": tasks_active,
        "tasks_blocked": tasks_blocked,
        "projects_active": len([p for p in projects if p.get("status") == "active"]),
        "llm_mode": llm_mode,
    }


# Cache the dashboard summary. The page polls this every ~10s, but the
# underlying file-walk over goals + tasks + projects + per-goal pursuit
# status is intrinsically O(N goals + N tasks + N projects), with each
# goal triggering a SEPARATE pursuit-status file read. On a vault with
# 11 active goals we measured 22 seconds. Cache for 8s — tight enough
# the dashboard feels live, loose enough to absorb burst polling.
_PROGRESS_SUMMARY_CACHE: dict[int, dict] = {}
_PROGRESS_SUMMARY_TTL = 8.0


@router.get("/progress-summary")
async def get_progress_summary(request: Request) -> dict:
    """Aggregated progress summary for the dashboard card.

    Walks all goals + tasks + projects on disk plus a per-goal pursuit
    status read. That's seconds of synchronous file I/O — runs off the
    event loop so concurrent dashboard polls don't queue behind it,
    AND cached for 8s so a burst of dashboard polls share one walk.
    """
    import asyncio

    cache_key = id(request.app)
    now = _time_goals.monotonic()
    cached = _PROGRESS_SUMMARY_CACHE.get(cache_key)
    if cached and (now - cached["ts"]) < _PROGRESS_SUMMARY_TTL and cached.get("data") is not None:
        return cached["data"]

    coach = request.app.state.coach
    task_svc = request.app.state.task_service
    project_svc = request.app.state.project_service

    from agntspace.infra.config import get_settings
    try:
        llm_mode = get_settings().llm.mode
    except Exception:
        llm_mode = "cloud"

    out = await asyncio.to_thread(_build_progress_summary, coach, task_svc, project_svc, llm_mode)
    _PROGRESS_SUMMARY_CACHE[cache_key] = {"ts": now, "data": out}
    return out


@router.get("/halted")
async def list_halted_goals(request: Request, hours: int = 6) -> list[dict]:
    """Return goals in ``pursuing`` status whose last_block_at is within
    ``hours``. Feeds the dashboard chip + goal-modal halted banner."""
    coach = request.app.state.coach
    try:
        return coach.list_halted_goals(max_age_hours=hours)
    except Exception:
        return []


@router.get("/nudges")
async def get_nudges(request: Request) -> list[str]:
    """Get coaching nudges for stalled goals and deadlines."""
    coach = request.app.state.coach
    return coach.generate_nudges()


@router.get("/stalls")
async def get_stalls(request: Request) -> list[dict]:
    """Detect stalled goals."""
    coach = request.app.state.coach
    return coach.detect_stalls()


@router.get("/{slug}")
async def get_goal(request: Request, slug: str) -> dict:
    """Get a single goal."""
    coach = request.app.state.coach
    goal = coach.get_goal(slug)
    if not goal:
        raise HTTPException(status_code=404, detail=f"Goal not found: {slug}")
    return goal


@router.put("/{slug}")
async def update_goal(request: Request, slug: str, body: GoalUpdateRequest) -> dict:
    """Update a goal's description and/or target date."""
    coach = request.app.state.coach
    try:
        coach.update_goal(
            slug=slug,
            description=body.description,
            target_date=body.target_date,
        )
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return {"slug": slug, "status": "updated"}


@router.post("/{slug}/rename")
async def rename_goal(request: Request, slug: str, body: GoalRenameRequest) -> dict:
    """Rename a goal: rewrite title, move file, update project references."""
    coach = request.app.state.coach
    try:
        result = coach.rename_goal(slug, body.title)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    activity = getattr(request.app.state, "activity_logger", None)
    if activity and result["slug"] != slug:
        activity.log(
            category="user_override",
            action="goal_renamed",
            summary=f"User renamed goal '{slug}' → '{result['slug']}' ('{body.title}')",
            details={"old_slug": slug, "new_slug": result["slug"], "projects_updated": result.get("projects_updated", 0)},
            importance="medium",
        )
    return {"slug": result["slug"], "projects_updated": result.get("projects_updated", 0)}


@router.patch("/{slug}/status")
async def update_goal_status(
    request: Request, slug: str, body: GoalStatusRequest
) -> dict:
    """Update a goal's status."""
    coach = request.app.state.coach
    try:
        coach.update_goal_status(slug, body.status)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except ValueError as exc:
        # Gap 30 fix: invalid status -> 400 with clear message
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"slug": slug, "status": body.status}


@router.get("/{slug}/projects")
async def list_goal_projects(request: Request, slug: str) -> list[dict]:
    """Reverse lookup: list projects whose related_goals contains this goal."""
    project_svc = request.app.state.project_service
    projects = project_svc.list_projects()
    return [p for p in projects if slug in (p.get("related_goals") or [])]


@router.post("/{slug}/projects/{project_slug}")
async def attach_goal_to_project(
    request: Request, slug: str, project_slug: str
) -> dict:
    """Attach this goal to a project (adds to project.related_goals)."""
    # The goal route authorizes create_task, but updating a project file
    # needs modify_project. Mark it authorized for this cross-entity write.
    try:
        from agntspace.activity.decisions import mark_action_authorized
        mark_action_authorized("modify_project")
    except Exception:
        pass

    coach = request.app.state.coach
    if not coach.get_goal(slug):
        raise HTTPException(status_code=404, detail=f"Goal not found: {slug}")
    project_svc = request.app.state.project_service
    detail = project_svc.get_project_detail(project_slug)
    if not detail:
        raise HTTPException(status_code=404, detail=f"Project not found: {project_slug}")

    goals = list(detail.get("related_goals") or [])
    if slug in goals:
        return {"slug": slug, "project": project_slug, "status": "already_linked"}
    goals.append(slug)
    project_svc.update_project(slug=project_slug, related_goals=goals)

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user_override",
            action="goal_attached_to_project",
            summary=f"User linked goal '{slug}' to project '{project_slug}'",
            details={"goal": slug, "project": project_slug},
            importance="medium",
        )
    return {"slug": slug, "project": project_slug, "status": "attached"}


@router.delete("/{slug}/projects/{project_slug}")
async def detach_goal_from_project(
    request: Request, slug: str, project_slug: str
) -> dict:
    """Detach this goal from a project (removes from project.related_goals)."""
    try:
        from agntspace.activity.decisions import mark_action_authorized
        mark_action_authorized("modify_project")
    except Exception:
        pass

    project_svc = request.app.state.project_service
    detail = project_svc.get_project_detail(project_slug)
    if not detail:
        raise HTTPException(status_code=404, detail=f"Project not found: {project_slug}")

    goals = list(detail.get("related_goals") or [])
    if slug not in goals:
        return {"slug": slug, "project": project_slug, "status": "not_linked"}
    goals.remove(slug)
    project_svc.update_project(slug=project_slug, related_goals=goals)

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user_override",
            action="goal_detached_from_project",
            summary=f"User unlinked goal '{slug}' from project '{project_slug}'",
            details={"goal": slug, "project": project_slug},
            importance="medium",
        )
    return {"slug": slug, "project": project_slug, "status": "detached"}


@router.get("/{slug}/pursuit")
async def get_pursuit_status(request: Request, slug: str) -> dict:
    """Get pursuit tracking status for a goal.

    Includes ``llm_mode`` so the UI can show mode-appropriate budget info:
    - cloud: dollar budget is meaningful, show $/$ progress bar
    - local: cost is always $0, show dispatch N/M progress bar instead

    Also enriches with trust info for the goal's domain so the UI can show
    the halted banner + path-to-next-level without a second round trip.
    """
    coach = request.app.state.coach
    try:
        status = coach.get_pursuit_status(slug)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    from agntspace.infra.config import get_settings
    try:
        llm_mode = get_settings().llm.mode
    except Exception:
        llm_mode = "cloud"
    status["llm_mode"] = llm_mode

    # Enrich with trust snapshot for the goal's domain
    trust_svc = getattr(request.app.state, "trust_service", None)
    contract = getattr(request.app.state, "contract", None)
    proactivity = contract.rules.proactivity if contract else "advisor"
    status["proactivity"] = proactivity
    if trust_svc:
        try:
            check = trust_svc.check_tool_trust(
                "create_task", proactivity, is_autonomous=True,
            )
            explain = trust_svc.explain_domain(status.get("domain", "general"))
            status["trust"] = {
                "effective_level": check.effective_level,
                "domain_trust": check.domain_trust,
                "effective_score": getattr(check, "effective_score", None),
                "path_to_next_level": explain.get("path_to_next_level", {}),
            }
        except Exception:
            status["trust"] = None
    else:
        status["trust"] = None

    # Attach next-tick ETA so the UI can show "Next check in N min" instead
    # of leaving the user guessing why an unblocked pursuit isn't
    # happening. The field name stays ``heartbeat`` for backward UI compat,
    # but the values reflect the goal-pursuit schedule when it differs from
    # the heartbeat schedule (since heartbeat_seconds=300 with
    # goal_pursuit_seconds=900 would otherwise mislead the user into
    # expecting a dispatch every 5 min).
    hb = getattr(request.app.state, "heartbeat", None)
    if hb:
        try:
            hb_status = await hb.get_status()
            pursuit_block = hb_status.get("goal_pursuit") or {}
            pursuit_interval = pursuit_block.get("interval_seconds") or hb_status.get(
                "goal_pursuit_interval_seconds"
            ) or hb_status.get("interval_seconds", 900)
            last_pursuit_at = pursuit_block.get("last_pursuit_at")
            next_pursuit_at = pursuit_block.get("next_pursuit_at")
            if last_pursuit_at and next_pursuit_at:
                # We've observed at least one pursuit tick — its recorded
                # timestamp + configured interval IS the exact ETA.
                status["heartbeat"] = {
                    "last_pulse_at": last_pursuit_at,
                    "next_pulse_at": next_pursuit_at,
                    "interval_seconds": pursuit_interval,
                }
            else:
                # No pursuit has run yet this process (fresh startup). Fall
                # back to the heartbeat cadence for the ETA — pursuit will
                # fire inside the next heartbeat tick at most.
                last_pulse = hb_status.get("last_pulse") or {}
                last_ts = last_pulse.get("ts") if isinstance(last_pulse, dict) else None
                hb_interval = hb_status.get("interval_seconds", 900)
                if last_ts:
                    from datetime import datetime as _dt
                    from datetime import timedelta as _td
                    try:
                        last_dt = _dt.fromisoformat(last_ts.replace("Z", "+00:00"))
                        next_dt = last_dt + _td(seconds=hb_interval)
                        status["heartbeat"] = {
                            "last_pulse_at": last_ts,
                            "next_pulse_at": next_dt.isoformat(),
                            "interval_seconds": pursuit_interval,
                        }
                    except (ValueError, TypeError):
                        status["heartbeat"] = {
                            "last_pulse_at": last_ts,
                            "next_pulse_at": None,
                            "interval_seconds": pursuit_interval,
                        }
                else:
                    status["heartbeat"] = {
                        "last_pulse_at": None,
                        "next_pulse_at": None,
                        "interval_seconds": pursuit_interval,
                    }
        except Exception:
            status["heartbeat"] = None
    else:
        status["heartbeat"] = None
    return status


@router.post("/{slug}/pursuit/start")
async def start_pursuit(request: Request, slug: str) -> dict:
    """Start autonomous pursuit of a goal."""
    coach = request.app.state.coach
    try:
        coach.set_pursuit_status(slug, "pursuing")
    except (FileNotFoundError, ValueError) as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"slug": slug, "pursuit_status": "pursuing"}


@router.post("/{slug}/pursuit/pause")
async def pause_pursuit(request: Request, slug: str) -> dict:
    """Pause autonomous pursuit of a goal."""
    coach = request.app.state.coach
    try:
        coach.set_pursuit_status(slug, "paused", reason="User paused")
    except (FileNotFoundError, ValueError) as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"slug": slug, "pursuit_status": "paused"}


@router.post("/{slug}/pursuit/dispatch-now")
async def dispatch_pursuit_now(request: Request, slug: str) -> dict:
    """Enqueue an immediate pursuit job instead of waiting for the next
    15-minute heartbeat tick.

    Runs the same gate checks the heartbeat would (trust unless
    ``trust_override``, dispatch cap, cloud-only budget) so we don't
    bypass the safety rails. On success, enqueues the same ``pursue_goal``
    job the heartbeat uses — the work queue handler is the single
    execution path.
    """
    coach = request.app.state.coach
    work_queue = getattr(request.app.state, "work_queue", None)
    if not work_queue:
        raise HTTPException(status_code=503, detail="Work queue not available")

    try:
        status = coach.get_pursuit_status(slug)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    if status.get("pursuit_status") != "pursuing":
        raise HTTPException(
            status_code=400,
            detail=f"Goal is not pursuing (status: {status.get('pursuit_status')})",
        )

    live = status.get("live_block")
    if live:
        raise HTTPException(
            status_code=409,
            detail={
                "message": "Goal is blocked — resolve the block before dispatching",
                "reason": live["reason"],
                "details": live["details"],
            },
        )

    goal = coach.get_goal(slug)
    budget = status["budget"]
    consumed = status["budget_consumed"]
    dispatches = status["dispatches"]
    max_dispatches = status["max_dispatches"]
    llm_mode = getattr(coach, "_llm_mode", None) or "local"
    local_mode = llm_mode == "local"

    try:
        job_id = await work_queue.enqueue(
            "pursue_goal",
            {
                "goal_slug": slug,
                "goal_title": goal.get("title", slug) if goal else slug,
                "goal_metric": status.get("success_metric", ""),
                "budget_remaining": budget - consumed,
                "dispatch_number": dispatches + 1,
                "max_dispatches": max_dispatches,
                "local_mode": local_mode,
            },
            deduplicate=True,
        )
    except Exception as exc:  # pragma: no cover
        raise HTTPException(status_code=500, detail=f"Failed to enqueue: {exc}") from exc

    if not job_id:
        # Deduplicated — same job already pending
        return {"slug": slug, "dispatched": False, "reason": "already_queued"}

    # Clear any stale recorded block; live_block is already None (checked above)
    try:
        coach.clear_block(slug)
    except Exception:
        pass  # best-effort — don't fail the dispatch on a clear error

    return {"slug": slug, "dispatched": True, "job_id": job_id}


@router.post("/{slug}/pursuit/raise-trust")
async def raise_trust_for_goal(request: Request, slug: str) -> dict:
    """Grant positive trust signals in this goal's domain to cross the
    Assistant threshold.

    The number of signals needed comes from ``explain_domain.path_to_next_level``
    — we grant exactly that many (capped at 10 to avoid runaway jumps) so the
    user sees an immediate promotion without over-vouching. Also clears any
    recorded block so the goal resumes on the next heartbeat tick.
    """
    coach = request.app.state.coach
    trust_svc = getattr(request.app.state, "trust_service", None)
    if not trust_svc:
        raise HTTPException(status_code=503, detail="Trust service not available")

    try:
        status = coach.get_pursuit_status(slug)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    domain = status.get("domain", "general")
    explain = trust_svc.explain_domain(domain)
    needed = int(explain.get("path_to_next_level", {}).get("signals_needed", 0) or 0)
    signals = max(1, min(needed, 10))

    last = None
    for _ in range(signals):
        last = trust_svc.record_feedback(domain, "good")

    try:
        coach.clear_block(slug)
    except Exception:
        pass

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user_override",
            action="goal_trust_raised",
            summary=f"User raised trust in '{domain}' by {signals} signal(s) to unblock goal '{slug}'",
            details={
                "goal_slug": slug,
                "domain": domain,
                "signals_granted": signals,
                "new_level": (last or {}).get("new_level"),
                "new_score": (last or {}).get("score"),
            },
            importance="high",
        )
    return {
        "slug": slug,
        "domain": domain,
        "signals_granted": signals,
        "new_level": (last or {}).get("new_level"),
        "new_score": (last or {}).get("score"),
        "effective_score": (last or {}).get("effective_score"),
    }


@router.post("/{slug}/pursuit/override")
async def set_goal_trust_override(
    request: Request, slug: str, body: TrustOverrideRequest,
) -> dict:
    """Toggle the per-goal trust-gate bypass.

    When ``enabled=True``, the heartbeat skips the trust check for this goal
    (dispatch cap + budget are still enforced). Lets the user say "I've
    explicitly approved this one, just run it."
    """
    coach = request.app.state.coach
    try:
        new_value = coach.set_trust_override(slug, body.enabled)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    if body.enabled:
        try:
            coach.clear_block(slug)
        except Exception:
            pass

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user_override",
            action="goal_trust_override_set" if body.enabled else "goal_trust_override_cleared",
            summary=f"User {'enabled' if body.enabled else 'disabled'} trust override for goal '{slug}'",
            details={"goal_slug": slug, "trust_override": new_value},
            importance="high",
        )
    return {"slug": slug, "trust_override": new_value}


@router.patch("/{slug}/pursuit/domain")
async def set_goal_domain(
    request: Request, slug: str, body: DomainUpdateRequest,
) -> dict:
    """Change which trust domain gates this goal's pursuit."""
    coach = request.app.state.coach
    try:
        new_domain = coach.set_domain(slug, body.domain)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    try:
        coach.clear_block(slug)
    except Exception:
        pass

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user",
            action="goal_domain_changed",
            summary=f"User changed goal '{slug}' domain to '{new_domain}'",
            details={"goal_slug": slug, "domain": new_domain},
            importance="medium",
        )
    return {"slug": slug, "domain": new_domain}


@router.post("/{slug}/pursuit/reset")
async def reset_pursuit(request: Request, slug: str) -> dict:
    """Reset pursuit counters (dispatches, cost, failures) so the goal can be re-pursued.

    Keeps the budget, metric, and max_dispatches config intact.
    Appends a RESET marker to the pursuit log.
    """
    coach = request.app.state.coach
    try:
        result = coach.reset_pursuit(slug)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user_override",
            action="goal_pursuit_reset",
            summary=f"User reset pursuit of goal '{slug}' "
                    f"(was: {result['reset']['old_dispatches']} dispatches, "
                    f"${result['reset']['old_budget_consumed']:.2f} spent)",
            details=result,
            importance="medium",
        )
    return result


@router.patch("/{slug}/pursuit")
async def update_pursuit_config(request: Request, slug: str, body: GoalPursuitUpdateRequest) -> dict:
    """Update pursuit configuration (metric, budget, max_attempts)."""
    import re as _re

    path = f"goals/{slug}.md"
    reader = request.app.state.vault
    if not reader.exists(path):
        raise HTTPException(status_code=404, detail=f"Goal not found: {slug}")

    doc = reader.read(path)
    content = doc.raw
    writer = request.app.state.writer

    if body.success_metric is not None:
        content = _re.sub(
            r'^success_metric:\s*".*"', f'success_metric: "{body.success_metric}"',
            content, count=1, flags=_re.MULTILINE,
        )
    if body.budget is not None:
        content = _re.sub(
            r"^budget:\s*[\d.]+", f"budget: {body.budget}",
            content, count=1, flags=_re.MULTILINE,
        )
    if body.max_dispatches is not None:
        if _re.search(r"^max_dispatches:", content, flags=_re.MULTILINE):
            content = _re.sub(
                r"^max_dispatches:\s*\d+", f"max_dispatches: {body.max_dispatches}",
                content, count=1, flags=_re.MULTILINE,
            )
        else:
            # Backfill for goals created before max_dispatches existed
            content = _re.sub(
                r"^(dispatches:\s*\d+)", rf"\1\nmax_dispatches: {body.max_dispatches}",
                content, count=1, flags=_re.MULTILINE,
            )
    if body.max_attempts is not None:
        content = _re.sub(
            r"^max_attempts:\s*\d+", f"max_attempts: {body.max_attempts}",
            content, count=1, flags=_re.MULTILINE,
        )

    writer.write(path, content, contract_action="create_task")
    return {"slug": slug, "status": "updated"}


@router.delete("/{slug}")
async def delete_goal(request: Request, slug: str) -> dict:
    """Delete a goal and clean up project references."""
    coach = request.app.state.coach
    try:
        result = coach.delete_goal(slug)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user",
            action="goal_deleted",
            summary=f"User deleted goal '{slug}' (cleaned {result['projects_cleaned']} project refs)",
            details=result,
            importance="medium",
        )
    return result


@router.get("/{slug}/history")
async def get_goal_history(
    request: Request, slug: str, limit: int = 100,
) -> dict:
    """Unified pursuit history — decisions, dispatches, costs, and progress log.

    Aggregates data from four sources into a single timeline:
    1. Decision log (filtered by goal_id)
    2. Dispatch log (filtered by goal_id)
    3. Cost log (per-goal breakdown)
    4. Pursuit log from the goal file itself
    """
    coach = request.app.state.coach
    try:
        status = coach.get_pursuit_status(slug)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    # 1. Decisions for this goal
    decisions_logger = getattr(request.app.state, "decisions", None)
    decisions = []
    if decisions_logger:
        try:
            decisions = await decisions_logger.query(goal_id=slug, limit=limit)
        except Exception:
            pass

    # 2. Dispatches for this goal
    orchestrator = getattr(request.app.state, "orchestrator", None)
    dispatches = []
    if orchestrator:
        try:
            dispatches = await orchestrator.get_dispatch_log(goal_id=slug, limit=limit)
        except Exception:
            pass

    # 3. Cost breakdown for this goal
    cost_tracker = getattr(request.app.state, "cost_tracker", None)
    cost_summary = {"total": 0.0, "entries": []}
    if cost_tracker:
        try:
            cost_summary["total"] = await cost_tracker.get_goal_cost(slug)
            cost_summary["entries"] = await cost_tracker.get_goal_cost_breakdown(slug, limit=limit)
        except Exception:
            pass

    # 4. Pursuit log from the goal file
    pursuit_log: list[str] = []
    try:
        reader = request.app.state.vault
        doc = reader.read(f"goals/{slug}.md")
        content = doc.content if hasattr(doc, "content") else ""
        if "## Pursuit Log" in content:
            log_section = content.split("## Pursuit Log", 1)[1]
            # Stop at next ## heading or end of file
            if "\n## " in log_section:
                log_section = log_section.split("\n## ", 1)[0]
            pursuit_log = [
                line.strip().lstrip("- ")
                for line in log_section.strip().split("\n")
                if line.strip().startswith("- ")
            ]
    except Exception:
        pass

    # Build unified timeline — merge all sources sorted by timestamp
    timeline: list[dict] = []

    for d in decisions:
        timeline.append({
            "type": "decision",
            "timestamp": d.get("timestamp", ""),
            "actor": d.get("actor", ""),
            "action": d.get("action_type", ""),
            "target": d.get("action_target", ""),
            "rationale": d.get("rationale", ""),
            "contract_result": d.get("contract_result", ""),
            "skills": d.get("context_skills", []),
            "correlation_id": d.get("correlation_id", ""),
        })

    for d in dispatches:
        timeline.append({
            "type": "dispatch",
            "timestamp": d.get("started_at", ""),
            "actor": d.get("agent_key", ""),
            "agent_name": d.get("agent_name", ""),
            "task": (d.get("task", "") or "")[:200],
            "content": (d.get("content", "") or "")[:200],
            "input_tokens": d.get("input_tokens", 0),
            "output_tokens": d.get("output_tokens", 0),
            "duration_ms": d.get("duration_ms", 0),
        })

    for entry in cost_summary.get("entries", []):
        timeline.append({
            "type": "cost",
            "timestamp": entry.get("timestamp", ""),
            "model": entry.get("model", ""),
            "input_tokens": entry.get("input_tokens", 0),
            "output_tokens": entry.get("output_tokens", 0),
            "cost": entry.get("estimated_cost", 0),
            "action": entry.get("action", ""),
        })

    # Sort timeline newest-first
    timeline.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
    timeline = timeline[:limit]

    from agntspace.infra.config import get_settings
    try:
        llm_mode = get_settings().llm.mode
    except Exception:
        llm_mode = "cloud"

    return {
        "slug": slug,
        "pursuit_status": status,
        "cost_total": cost_summary["total"],
        "llm_mode": llm_mode,
        "pursuit_log": pursuit_log,
        "timeline": timeline,
        "counts": {
            "decisions": len(decisions),
            "dispatches": len(dispatches),
            "cost_entries": len(cost_summary.get("entries", [])),
        },
    }


# ─────────────────────────────────────────────────────────────────
# Goal shaping — Ship 2
# ─────────────────────────────────────────────────────────────────
#
# When the owner creates (or opens an empty) goal, the UI offers a
# "Shape this goal" action that dispatches the `goal-shape` skill.
# The skill is read-only: it produces a JSON envelope of proposals
# (relevant existing projects, a new project if none fit, draft tasks,
# a measurable success metric, suggested team).  The owner approves
# each proposal via the existing attach / create / patch endpoints —
# nothing is auto-applied.
#
# This follows Rule 17 Tier A: Agent.invoke_skill mediates the
# dispatch, correlation_scope + decision log + zero-effect gate all
# fire automatically.  Rule 12: tuning knobs live in
# defaults/skills/core/goal-shape/config/strategy.yaml, not here.


def _parse_shape_envelope(content: str, max_chars: int) -> dict | None:
    """Extract the first valid JSON object from the LLM narrative.

    Tries, in order:
      1. First ``json`` fenced code block.
      2. ``json.JSONDecoder.raw_decode`` starting at the first ``{``.

    Returns None when neither succeeds — the route logs the failure
    and returns 422 so the UI can show "Try again" without erroring.
    """
    import json as _json
    import re as _re

    if not content:
        return None
    payload = content[:max_chars]

    # Prefer fenced blocks because the SKILL.md prompt asks for exactly
    # one, and models often emit valid JSON inside a fence even when
    # they wrap it in extra prose.
    fenced = _re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", payload)
    if fenced:
        try:
            return _json.loads(fenced.group(1))
        except _json.JSONDecodeError:
            pass

    # Fall back to raw_decode starting at the first `{` — this handles
    # the case where the model forgot the fences but the JSON is still
    # the first structured block.
    # Scan every `{` in the payload and try raw_decode at each one.
    # Local LLMs often emit prose before the JSON (e.g. "Accomplished: ...
    # {variable} ... here's the JSON: {...}") — we want to find the first
    # position that produces a valid dict with at least one of our expected
    # top-level fields, not just the first brace.
    expected_keys = {"relevant_projects", "new_project", "draft_tasks",
                     "success_metric", "suggested_team"}
    best_obj: dict | None = None
    for idx in range(len(payload)):
        if payload[idx] != "{":
            continue
        try:
            obj, _ = _json.JSONDecoder().raw_decode(payload[idx:])
        except _json.JSONDecodeError:
            continue
        if not isinstance(obj, dict):
            continue
        # Prefer the first dict that has at least one expected top-level key.
        # If none match, fall back to the first parseable dict so partial
        # malformed output still produces SOMETHING (better than 422).
        if expected_keys & obj.keys():
            return obj
        if best_obj is None:
            best_obj = obj
    return best_obj


def _normalize_shape(envelope: dict, limits: dict, existing_slugs: set[str]) -> dict:
    """Apply server-side caps + filters + validation to LLM output.

    Invariants this enforces so the UI never sees malformed data:
      - `relevant_projects` entries must reference a real existing slug
        (otherwise the LLM hallucinated — drop it).
      - Confidence below ``min_relevance_confidence`` → dropped.
      - All string fields truncated to ``parser.max_field_chars``.
      - All arrays capped at their respective ``max_*`` limit.
      - Missing/null top-level fields default to empty array / None.
    """
    max_rel = int(limits.get("max_relevant_projects", 3))
    max_tasks = int(limits.get("max_draft_tasks", 5))
    max_team = int(limits.get("max_suggested_team", 5))
    min_conf = float(limits.get("min_relevance_confidence", 0.4))
    max_field = int(limits.get("_max_field_chars", 1200))

    def _str(v: object) -> str:
        return (str(v) if v is not None else "")[:max_field]

    def _float(v: object, default: float = 0.0) -> float:
        try:
            return float(v)  # type: ignore[arg-type]
        except (TypeError, ValueError):
            return default

    raw_rel = envelope.get("relevant_projects") or []
    relevant = []
    for item in raw_rel:
        if not isinstance(item, dict):
            continue
        slug = _str(item.get("slug"))
        if slug not in existing_slugs:
            continue  # hallucinated — drop
        conf = _float(item.get("confidence"), 0.5)
        if conf < min_conf:
            continue
        relevant.append({
            "slug": slug,
            "title": _str(item.get("title") or slug),
            "reason": _str(item.get("reason")),
            "confidence": round(conf, 2),
        })
    relevant = relevant[:max_rel]

    raw_new = envelope.get("new_project")
    new_project = None
    if isinstance(raw_new, dict) and raw_new.get("title"):
        new_project = {
            "title": _str(raw_new.get("title")),
            "description": _str(raw_new.get("description")),
        }

    # When the LLM proposes BOTH a relevant existing project AND a new
    # project, drop the new one — SKILL.md rule says pick one. Existing
    # wins because it preserves the owner's prior work.
    if relevant and new_project:
        new_project = None

    raw_tasks = envelope.get("draft_tasks") or []
    tasks = []
    for item in raw_tasks:
        if not isinstance(item, dict):
            continue
        title = _str(item.get("title"))
        if not title:
            continue
        tasks.append({
            "title": title,
            "description": _str(item.get("description")),
            "project_hint": _str(item.get("project_hint")),
        })
    tasks = tasks[:max_tasks]

    metric_raw = envelope.get("success_metric")
    metric = None
    if isinstance(metric_raw, dict) and metric_raw.get("text"):
        metric = {
            "text": _str(metric_raw.get("text")),
            "rationale": _str(metric_raw.get("rationale")),
        }

    raw_team = envelope.get("suggested_team") or []
    team = []
    for item in raw_team:
        if not isinstance(item, dict):
            continue
        agent = _str(item.get("agent"))
        if not agent:
            continue
        team.append({
            "agent": agent,
            "reason": _str(item.get("reason")),
        })
    team = team[:max_team]

    return {
        "relevant_projects": relevant,
        "new_project": new_project,
        "draft_tasks": tasks,
        "success_metric": metric,
        "suggested_team": team,
    }


@router.post("/{slug}/shape")
async def shape_goal(request: Request, slug: str) -> dict:
    """Propose relevant projects, draft tasks, and a success metric for a goal.

    Read-only: the endpoint returns proposals; the owner approves each one
    via existing endpoints (attach_goal_to_project, create_project,
    create_task, PATCH /pursuit). Nothing is written here.

    Structured output via tool-calling. The `goal-shape` skill grants the
    subagent a `submit_shape_proposals` tool whose JSON Schema IS the
    contract — the LLM MUST call it with typed args. The tool handler
    stores the args in ``app.state.shape_proposals[correlation_id]``; the
    route reads them back deterministically after ``invoke_skill``.
    A JSON-parser fallback covers cloud models that (rarely) return
    valid JSON directly in their narrative without calling the tool.
    """
    import asyncio

    coach = request.app.state.coach
    goal = coach.get_goal(slug)
    if not goal:
        raise HTTPException(status_code=404, detail=f"Goal not found: {slug}")

    skill_loader = getattr(request.app.state, "skill_loader", None)
    strategy: dict = {}
    if skill_loader is not None:
        cfg = skill_loader.load_skill_config_file("goal-shape", "strategy.yaml")
        if isinstance(cfg, dict):
            strategy = cfg
    if strategy.get("enabled", True) is False:
        raise HTTPException(status_code=409, detail="Goal shaping is disabled in strategy.yaml")

    limits = dict(strategy.get("limits") or {})
    parser_cfg = dict(strategy.get("parser") or {})
    max_content = int(parser_cfg.get("max_content_chars", 20000))
    limits["_max_field_chars"] = int(parser_cfg.get("max_field_chars", 1200))
    agent_name = str(strategy.get("agent") or "planner")

    goal_meta = goal.get("metadata") or {}
    goal_title = str(goal_meta.get("title") or slug)
    goal_body = str(goal.get("content") or "")[:2000]

    args = {
        "goal_slug": slug,
        "goal_title": goal_title,
        "goal_description": goal_body,
        "goal_type": goal_meta.get("type") or goal_meta.get("goal_type") or "goal",
        "target_date": goal_meta.get("target_date") or "",
    }

    agent = request.app.state.agent
    try:
        from agntspace.agent.agent import SkillZeroEffect
    except Exception:
        SkillZeroEffect = Exception  # type: ignore[misc,assignment]

    # Ensure the capture dict exists on app.state even for stub/test apps
    # that didn't run main.py's wiring.
    if not hasattr(request.app.state, "shape_proposals"):
        request.app.state.shape_proposals = {}

    # Per-call timeout so a hung LLM can't pin the UI forever. The
    # planner needs room for list_projects + submit_shape_proposals,
    # which on a slow local model can take 60s+.
    timeout_s = float(strategy.get("timeout_seconds", 120))

    try:
        outcome = await asyncio.wait_for(
            agent.invoke_skill(
                "goal-shape",
                args,
                agent_name=agent_name,
                caller="ui-shape",
            ),
            timeout=timeout_s,
        )
    except TimeoutError as exc:
        raise HTTPException(
            status_code=504,
            detail=(
                f"Shaper took longer than {int(timeout_s)}s. Local LLMs "
                "can be slow — retry, or switch to cloud mode in Settings."
            ),
        ) from exc
    except SkillZeroEffect as zero:
        raise HTTPException(
            status_code=503,
            detail=(
                "Shaping produced no effect — the subagent did not call any "
                "tools. This usually means the LLM is offline or refused the "
                "task. Retry in a moment."
            ),
        ) from zero
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Shaping failed: {exc}") from exc

    # PRIMARY PATH: read structured args captured by the submit_shape_proposals
    # tool during subagent dispatch. Keyed by correlation_id (contextvar),
    # so concurrent shape calls don't collide.
    corr_id = outcome.correlation_id
    captured = request.app.state.shape_proposals.pop(corr_id, None)

    envelope: dict | None = None
    if isinstance(captured, dict):
        envelope = captured
    else:
        # FALLBACK: parse the narrative as JSON. Covers cloud models that
        # return JSON directly and edge cases where the capture dict was
        # cleared (e.g. correlation_id mismatch).
        envelope = _parse_shape_envelope(outcome.content or "", max_content)

    if envelope is None:
        activity = getattr(request.app.state, "activity_logger", None)
        if activity:
            activity.log(
                category="system",
                action="goal_shape_parse_failed",
                summary=f"goal-shape returned no structured output for goal '{slug}'",
                details={
                    "goal_slug": slug,
                    "correlation_id": corr_id,
                    "captured": bool(captured),
                    "content_preview": (outcome.content or "")[:500],
                    "mutations": outcome.mutations,
                },
                importance="medium",
            )
        raise HTTPException(
            status_code=422,
            detail=(
                "Shaper did not call submit_shape_proposals. The LLM "
                "narrated instead of submitting structured output. "
                "Retry usually fixes this."
            ),
        )

    existing_slugs = {p["slug"] for p in request.app.state.project_service.list_projects()}
    proposals = _normalize_shape(envelope, limits, existing_slugs)

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user",
            action="goal_shape_requested",
            summary=f"User requested shape proposals for goal '{slug}'",
            details={
                "goal_slug": slug,
                "correlation_id": corr_id,
                "source": "tool_call" if captured else "json_parse",
                "relevant_projects": len(proposals["relevant_projects"]),
                "new_project": bool(proposals["new_project"]),
                "draft_tasks": len(proposals["draft_tasks"]),
                "metric": bool(proposals["success_metric"]),
            },
            importance="low",
        )

    return {
        "goal_slug": slug,
        "correlation_id": corr_id,
        "proposals": proposals,
        "mutations": outcome.mutations,
        "source": "tool_call" if captured else "json_parse",
    }


# ─────────────────────────────────────────────────────────────────
# Pending questions — Ship 3 (autonomous goal pursuit)
# ─────────────────────────────────────────────────────────────────
#
# The main agent queues a question via the ask_goal_question tool
# (handler calls add_pending_question). The owner sees it on the
# dashboard + goal modal and answers through these endpoints. The
# answered question moves to answered_questions, becomes context
# the agent can read on the next dispatch.


class GoalQuestionCreateRequest(BaseModel):
    question: str
    importance: str = "medium"
    context: str = ""


class GoalQuestionAnswerRequest(BaseModel):
    answer: str


@router.get("/questions/pending")
async def list_all_pending_questions(request: Request) -> list[dict]:
    """Every unanswered question across all active goals.

    Enriched with goal_slug + goal_title for the dashboard alert card.
    Sorted high-importance first, then oldest-first.
    """
    coach = request.app.state.coach
    try:
        return coach.list_all_pending_questions()
    except Exception:
        return []


@router.get("/{slug}/questions")
async def list_goal_questions(request: Request, slug: str) -> dict:
    """Pending + recent answered questions for one goal."""
    coach = request.app.state.coach
    try:
        pending = coach.list_pending_questions(slug)
        answered = coach.list_answered_questions(slug, limit=20)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return {"pending": pending, "answered": answered}


@router.post("/{slug}/questions")
async def add_goal_question(
    request: Request, slug: str, body: GoalQuestionCreateRequest,
) -> dict:
    """Manual entry point for adding a pending question (debugging /
    API consumers). The agent itself adds questions via the
    ``ask_goal_question`` tool during pursuit — same underlying
    CoachService method.
    """
    coach = request.app.state.coach
    try:
        entry = coach.add_pending_question(
            slug,
            body.question,
            importance=body.importance,
            context=body.context,
        )
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="system",
            action="goal_question_asked",
            summary=f"Question queued on goal '{slug}': {body.question[:80]}",
            details={"goal_slug": slug, **entry},
            importance=body.importance if body.importance in ("low", "medium", "high") else "medium",
        )
    return entry


@router.post("/{slug}/questions/{qid}/answer")
async def answer_goal_question(
    request: Request, slug: str, qid: str, body: GoalQuestionAnswerRequest,
) -> dict:
    """Owner answers a pending question. Moves it from pending_questions
    to answered_questions with the answer + timestamp recorded.

    After answering, the agent's next heartbeat dispatch reads the
    answered_questions list as context — the answer shapes the next step.
    """
    coach = request.app.state.coach
    try:
        entry = coach.answer_pending_question(slug, qid, body.answer)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except KeyError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user",
            action="goal_question_answered",
            summary=f"User answered question on goal '{slug}': {entry.get('question','')[:60]}",
            details={"goal_slug": slug, "question_id": qid, "answer_preview": body.answer[:200]},
            importance="medium",
        )
    return entry


# ─────────────────────────────────────────────────────────────────
# Metric observations / retrospectives / milestones — Ship 4 (PDCA)
# ─────────────────────────────────────────────────────────────────
#
# Close the Plan → Do → Check → Act loop:
#   - metric observations: the Check substrate — structured KPI
#     tracking. Agent (or owner) records observations; the UI shows a
#     progress bar + trend.
#   - retrospectives: the Act substrate — periodic agent-generated
#     synthesis ({on_track/off_track/stuck} + recommended action).
#     Owner accepts or rejects; accepted pause/mark_achieved cascade.
#   - milestones: structural checkpoints breaking the metric into
#     human-readable waypoints with done/not-done state.


class MetricObservationRequest(BaseModel):
    value: str
    numeric_value: float | None = None
    source: str = "manual"
    note: str = ""


class RetrospectiveSubmitRequest(BaseModel):
    verdict: str
    insights: str
    recommended_action: str
    recommendation_note: str = ""
    dispatch_count: int = 0


class RetrospectiveApplyRequest(BaseModel):
    accept: bool
    note: str = ""


class MilestoneCreateRequest(BaseModel):
    text: str
    source: str = "owner"


class MilestoneCompleteRequest(BaseModel):
    done: bool = True


# ── Metric observations ────────────────────────────────────────────

@router.get("/{slug}/metric")
async def get_metric_progress(request: Request, slug: str) -> dict:
    """Return metric progress summary for the UI progress bar."""
    coach = request.app.state.coach
    try:
        progress = coach.metric_progress(slug)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    observations = coach.list_metric_observations(slug)
    return {"progress": progress, "observations": observations}


@router.post("/{slug}/metric/observation")
async def record_metric_observation(
    request: Request, slug: str, body: MetricObservationRequest,
) -> dict:
    """Record one observation against the goal's success metric."""
    coach = request.app.state.coach
    try:
        entry = coach.record_metric_observation(
            slug,
            body.value,
            numeric_value=body.numeric_value,
            source=body.source,
            note=body.note,
        )
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="system" if body.source in ("agent", "agent_estimate", "delegated_task") else "user",
            action="metric_observation_recorded",
            summary=f"Metric observation on '{slug}': {body.value[:80]}",
            details={"goal_slug": slug, **entry},
            importance="low",
        )
    return entry


# ── Retrospectives ─────────────────────────────────────────────────

@router.get("/{slug}/retrospectives")
async def list_goal_retrospectives(request: Request, slug: str) -> list[dict]:
    """Newest-first retrospectives for a goal."""
    coach = request.app.state.coach
    try:
        return coach.list_retrospectives(slug)
    except Exception:
        return []


@router.post("/{slug}/retrospectives")
async def submit_retrospective(
    request: Request, slug: str, body: RetrospectiveSubmitRequest,
) -> dict:
    """Agent (or manual/debug caller) submits a retrospective verdict +
    recommendation. The owner reviews and calls /apply to accept or reject."""
    coach = request.app.state.coach
    try:
        entry = coach.add_retrospective(
            slug,
            verdict=body.verdict,
            insights=body.insights,
            recommended_action=body.recommended_action,
            recommendation_note=body.recommendation_note,
            dispatch_count=body.dispatch_count,
        )
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="system",
            action="retrospective_submitted",
            summary=f"Retrospective on '{slug}': {body.verdict} → {body.recommended_action}",
            details={"goal_slug": slug, **entry},
            importance="medium" if body.verdict == "on_track" else "high",
        )
    return entry


@router.post("/{slug}/retrospectives/{retro_id}/apply")
async def apply_retrospective(
    request: Request, slug: str, retro_id: str, body: RetrospectiveApplyRequest,
) -> dict:
    """Owner accepts or rejects the agent's retrospective recommendation.

    Accepting ``pause`` moves the goal to paused; accepting ``mark_achieved``
    moves the goal to achieved. Other actions are purely informational —
    the agent reads them as context on next dispatch.
    """
    coach = request.app.state.coach
    try:
        entry = coach.apply_retrospective(slug, retro_id, accept=body.accept, note=body.note)
    except (FileNotFoundError, KeyError) as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user" if body.accept else "user_override",
            action="retrospective_accepted" if body.accept else "retrospective_rejected",
            summary=f"User {'accepted' if body.accept else 'rejected'} retrospective on '{slug}'",
            details={"goal_slug": slug, "retro_id": retro_id, **entry},
            importance="medium",
        )
    return entry


# ── Milestones ─────────────────────────────────────────────────────

@router.get("/{slug}/milestones")
async def list_goal_milestones(request: Request, slug: str) -> dict:
    """Milestone list + summary (total, done, percent)."""
    coach = request.app.state.coach
    try:
        milestones = coach.list_milestones(slug)
        progress = coach.milestones_progress(slug)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return {"milestones": milestones, "progress": progress}


@router.post("/{slug}/milestones")
async def add_goal_milestone(
    request: Request, slug: str, body: MilestoneCreateRequest,
) -> dict:
    coach = request.app.state.coach
    try:
        entry = coach.add_milestone(slug, body.text, source=body.source)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user" if body.source == "owner" else "system",
            action="milestone_added",
            summary=f"Milestone added to '{slug}': {body.text[:80]}",
            details={"goal_slug": slug, **entry},
            importance="low",
        )
    return entry


@router.post("/{slug}/milestones/{milestone_id}/complete")
async def complete_goal_milestone(
    request: Request, slug: str, milestone_id: str, body: MilestoneCompleteRequest,
) -> dict:
    coach = request.app.state.coach
    try:
        entry = coach.complete_milestone(slug, milestone_id, done=body.done)
    except (FileNotFoundError, KeyError) as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    activity = getattr(request.app.state, "activity_logger", None)
    if activity:
        activity.log(
            category="user",
            action="milestone_completed" if body.done else "milestone_reopened",
            summary=f"Milestone {'completed' if body.done else 'reopened'} on '{slug}'",
            details={"goal_slug": slug, "milestone_id": milestone_id, **entry},
            importance="medium",
        )
    return entry


@router.delete("/{slug}/milestones/{milestone_id}")
async def delete_goal_milestone(
    request: Request, slug: str, milestone_id: str,
) -> dict:
    coach = request.app.state.coach
    try:
        coach.delete_milestone(slug, milestone_id)
    except (FileNotFoundError, KeyError) as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return {"slug": slug, "milestone_id": milestone_id, "status": "deleted"}
