"""
Pulse tool handler — founder-ready project health reports.

Chains multiple quality operations and translates their developer-facing
metrics into a single, plain-language briefing suitable for non-technical
founders and product owners.

Operations:
  run       — Generate a full health report
  radar     — Compact 6-axis readiness radar chart
  persona   — View app through a specific persona's eyes
  timeline  — Auto-detected milestone timeline
  decisions — Founder decision queue with choices
"""

from __future__ import annotations

import json
import logging
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from dazzle.core.ir.appspec import AppSpec

from .common import DEFAULT_STEP_TIMEOUT, error_response, extract_progress, wrap_handler_errors

logger = logging.getLogger("dazzle.mcp.handlers.pulse")


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------


def pulse_run_impl(
    project_path: Path,
    *,
    business_context: str | None = None,
) -> dict[str, Any]:
    """Generate a founder-ready project health report.

    Chains five data sources:
      1. pipeline(run, summary=True) — quality & validation
      2. story(coverage) — feature completeness
      3. sitespec(coherence) — live-site readiness
      4. policy(coverage) — security posture
      5. semantics(compliance) — regulatory alignment

    Returns a dict with both structured metrics and a markdown narrative.
    """
    t0 = time.monotonic()

    # Run collectors in parallel with per-collector timeout to prevent
    # any single slow handler from stalling the entire pulse report.
    pipeline_data, stories_data, coherence_data, policy_data, compliance_data = (
        _collect_all_parallel(project_path, business_context)
    )

    project_name = _extract_project_name(pipeline_data, project_path)

    radar = _compute_radar(
        pipeline_data, stories_data, coherence_data, policy_data, compliance_data
    )
    health_score = _composite_health(radar)

    needs_input = _founder_decisions(stories_data, coherence_data, policy_data)
    recent_wins = _recent_wins(pipeline_data, stories_data, policy_data)
    blockers = _framework_blockers(pipeline_data, coherence_data)

    markdown = _render_markdown(
        project_name=project_name,
        health_score=health_score,
        radar=radar,
        needs_input=needs_input,
        recent_wins=recent_wins,
        blockers=blockers,
        stories_data=stories_data,
        pipeline_data=pipeline_data,
    )

    duration_ms = (time.monotonic() - t0) * 1000

    return {
        "status": "complete",
        "project_name": project_name,
        "health_score": round(health_score, 1),
        "radar": radar,
        "needs_input": needs_input,
        "recent_wins": recent_wins,
        "blockers": blockers,
        "markdown": markdown,
        "duration_ms": round(duration_ms, 1),
    }


@wrap_handler_errors
def run_pulse_handler(project_path: Path, args: dict[str, Any]) -> str:
    """Generate a founder-ready project health report."""
    progress = extract_progress(args)
    progress.advance_sync(1, 6, "Running quality pipeline")
    progress.advance_sync(2, 6, "Checking story coverage")
    progress.advance_sync(3, 6, "Site coherence check")
    progress.advance_sync(4, 6, "Policy coverage")
    progress.advance_sync(5, 6, "Compliance analysis")
    progress.advance_sync(6, 6, "Building health report")
    result = pulse_run_impl(
        project_path,
        business_context=args.get("business_context"),
    )
    return json.dumps(result, indent=2)


def pulse_radar_impl(
    project_path: Path,
    *,
    business_context: str | None = None,
) -> dict[str, Any]:
    """Return just the 6-axis readiness radar with plain-language axis labels.

    Lighter than ``run`` — skips narrative generation, returns radar scores
    and a compact ASCII chart suitable for quick status checks.
    """
    t0 = time.monotonic()

    pipeline_data, stories_data, coherence_data, policy_data, compliance_data = (
        _collect_all_parallel(project_path, business_context)
    )

    radar = _compute_radar(
        pipeline_data, stories_data, coherence_data, policy_data, compliance_data
    )
    health_score = _composite_health(radar)
    project_name = _extract_project_name(pipeline_data, project_path)

    chart_lines = [f"{project_name} — {health_score:.0f}% Launch Ready", ""]
    for axis in _RADAR_AXES:
        score = radar.get(axis, 0)
        bar = _progress_bar(score)
        label = _AXIS_LABELS.get(axis, axis.title())
        chart_lines.append(f"  {label:24s} {bar} {score:.0f}%")

    duration_ms = (time.monotonic() - t0) * 1000

    return {
        "status": "complete",
        "project_name": project_name,
        "health_score": round(health_score, 1),
        "radar": radar,
        "chart": "\n".join(chart_lines),
        "duration_ms": round(duration_ms, 1),
    }


@wrap_handler_errors
def radar_pulse_handler(project_path: Path, args: dict[str, Any]) -> str:
    """Return just the 6-axis readiness radar with plain-language axis labels."""
    result = pulse_radar_impl(
        project_path,
        business_context=args.get("business_context"),
    )
    return json.dumps(result, indent=2)


def pulse_persona_impl(
    project_path: Path,
    *,
    persona_name: str,
) -> dict[str, Any]:
    """Show the app through a specific persona's eyes.

    Lists what this persona can do (covered stories), what's partially
    working, and what's not started — all in plain language.
    """
    t0 = time.monotonic()

    with ThreadPoolExecutor(max_workers=2, thread_name_prefix="pulse-per") as pool:
        fut_list = pool.submit(_collect_story_list, project_path)
        fut_cov = pool.submit(_collect_stories, project_path)

    story_list_data = _safe_result(fut_list, "story_list")
    coverage_data = _safe_result(fut_cov, "stories")

    all_stories: list[dict[str, Any]] = story_list_data.get("stories", [])
    persona_stories = [s for s in all_stories if s.get("actor", "").lower() == persona_name.lower()]

    if not persona_stories and all_stories:
        persona_stories = [
            s for s in all_stories if persona_name.lower() in s.get("actor", "").lower()
        ]

    coverage_map: dict[str, str] = {}
    for item in coverage_data.get("stories", []):
        coverage_map[item.get("story_id", "")] = item.get("status", "unknown")

    working: list[str] = []
    partial: list[str] = []
    not_started: list[str] = []
    for story in persona_stories:
        sid = story.get("story_id", "")
        title = story.get("title", sid)
        cov_status = coverage_map.get(sid, "uncovered")
        if cov_status == "covered":
            working.append(title)
        elif cov_status == "partial":
            partial.append(title)
        else:
            not_started.append(title)

    total = len(persona_stories)
    working_count = len(working)
    experience_score = round((working_count / total * 100) if total > 0 else 0, 1)

    md_lines = [f"Viewing as: {persona_name}", ""]
    if working:
        for title in working:
            md_lines.append(f"  [ok] {title}")
    if partial:
        for title in partial:
            md_lines.append(f"  [..] {title}")
    if not_started:
        for title in not_started:
            md_lines.append(f"  [  ] {title}")
    md_lines.append("")
    md_lines.append(f"{persona_name}'s experience: {experience_score:.0f}/100")

    duration_ms = (time.monotonic() - t0) * 1000

    return {
        "status": "complete",
        "persona": persona_name,
        "experience_score": experience_score,
        "total_stories": total,
        "working": working,
        "partial": partial,
        "not_started": not_started,
        "markdown": "\n".join(md_lines),
        "duration_ms": round(duration_ms, 1),
    }


@wrap_handler_errors
def persona_pulse_handler(project_path: Path, args: dict[str, Any]) -> str:
    """Show the app through a specific persona's eyes."""
    persona_name = args.get("persona")
    if not persona_name:
        return error_response("persona parameter is required")
    result = pulse_persona_impl(project_path, persona_name=persona_name)
    return json.dumps(result, indent=2)


def pulse_timeline_impl(
    project_path: Path,
    *,
    business_context: str | None = None,
) -> dict[str, Any]:
    """Auto-detected milestone timeline.

    Derives milestones from current project state: what has been achieved
    (filled) and what lies ahead (open). Uses timestamps from stories
    and test designs where available.
    """
    t0 = time.monotonic()

    # Run collectors in parallel
    with ThreadPoolExecutor(max_workers=5, thread_name_prefix="pulse-tl") as pool:
        fut_pipeline = pool.submit(_collect_pipeline, project_path)
        fut_stories = pool.submit(_collect_stories, project_path)
        fut_story_list = pool.submit(_collect_story_list, project_path)
        fut_policy = pool.submit(_collect_policy, project_path)
        fut_coherence = pool.submit(_collect_coherence, project_path, business_context)

    pipeline_data = _safe_result(fut_pipeline, "pipeline")
    stories_data = _safe_result(fut_stories, "stories")
    story_list_data = _safe_result(fut_story_list, "story_list")
    policy_data = _safe_result(fut_policy, "policy")
    coherence_data = _safe_result(fut_coherence, "coherence")

    milestones = _derive_milestones(
        pipeline_data, stories_data, story_list_data, policy_data, coherence_data
    )

    md_lines = [f"{_extract_project_name(pipeline_data, project_path)} — Milestone Timeline", ""]
    done_count = sum(1 for m in milestones if m["done"])
    total_count = len(milestones)
    md_lines.append(f"  {done_count} of {total_count} milestones reached")
    md_lines.append("")

    for m in milestones:
        marker = "[x]" if m["done"] else "[ ]"
        line = f"  {marker} {m['label']}"
        if m.get("detail"):
            line += f" — {m['detail']}"
        md_lines.append(line)

    duration_ms = (time.monotonic() - t0) * 1000

    return {
        "status": "complete",
        "milestones": milestones,
        "done": done_count,
        "total": total_count,
        "markdown": "\n".join(md_lines),
        "duration_ms": round(duration_ms, 1),
    }


@wrap_handler_errors
def timeline_pulse_handler(project_path: Path, args: dict[str, Any]) -> str:
    """Auto-detected milestone timeline."""
    result = pulse_timeline_impl(
        project_path,
        business_context=args.get("business_context"),
    )
    return json.dumps(result, indent=2)


def pulse_decisions_impl(
    project_path: Path,
    *,
    business_context: str | None = None,
) -> dict[str, Any]:
    """Founder decision queue with actionable choices.

    Returns decisions that require founder judgment — business context,
    risk tolerance, and taste. Each decision has multiple-choice options.
    """
    t0 = time.monotonic()

    # Run collectors in parallel
    with ThreadPoolExecutor(max_workers=4, thread_name_prefix="pulse-dec") as pool:
        fut_stories = pool.submit(_collect_stories, project_path)
        fut_coherence = pool.submit(_collect_coherence, project_path, business_context)
        fut_policy = pool.submit(_collect_policy, project_path)
        fut_pipeline = pool.submit(_collect_pipeline, project_path)

    stories_data = _safe_result(fut_stories, "stories")
    coherence_data = _safe_result(fut_coherence, "coherence")
    policy_data = _safe_result(fut_policy, "policy")
    pipeline_data = _safe_result(fut_pipeline, "pipeline")

    decisions = _decision_queue(stories_data, coherence_data, policy_data, pipeline_data)

    md_lines: list[str] = []
    if decisions:
        md_lines.append(f"Decisions waiting ({len(decisions)}):")
        md_lines.append("")
        for i, d in enumerate(decisions, 1):
            md_lines.append(f"  {i}. {d['category'].upper()} — {d['question']}")
            for opt in d.get("options", []):
                md_lines.append(f"     [{opt['key']}] {opt['label']}")
            md_lines.append("")
    else:
        md_lines.append("No decisions waiting — the agent has everything it needs.")

    duration_ms = (time.monotonic() - t0) * 1000

    return {
        "status": "complete",
        "decisions": decisions,
        "count": len(decisions),
        "markdown": "\n".join(md_lines),
        "duration_ms": round(duration_ms, 1),
    }


@wrap_handler_errors
def decisions_pulse_handler(project_path: Path, args: dict[str, Any]) -> str:
    """Founder decision queue with actionable choices."""
    result = pulse_decisions_impl(
        project_path,
        business_context=args.get("business_context"),
    )
    return json.dumps(result, indent=2)


def pulse_wfs_impl(
    project_path: Path,
    *,
    persona_filter: str | None = None,
) -> dict[str, Any]:
    """Workflow Friction Score — per-persona dashboard-to-workflow friction.

    Quantifies how many clicks/navigations a persona needs to go from their
    workspace dashboard to completing a core workflow.  Computed purely from
    the DSL IR — no running app required.

    WFS formula per story:
        WFS = C + (V × 0.5) + (D × 1.5) + (A × 0.75)

    Factors:
        C (Clicks)      — navigation hops from workspace to workflow entity
        V (Visibility)   — 0=on dashboard, 1=sidebar only, 2=not linked
        D (Discovery)    — 0=action link present, 1=no action link, 2=no region
        A (Ambiguity)    — 0=filtered region, 1=unfiltered list

    Rating: 0-2 excellent, 3-4 acceptable, 5+ needs work
    """
    from .common import load_project_appspec

    t0 = time.monotonic()

    appspec = load_project_appspec(project_path)
    if appspec is None:
        return {"status": "error", "error": "Could not load AppSpec from project"}

    results = compute_wfs(appspec, persona_filter=persona_filter)
    md_lines = _render_wfs_markdown(results)
    duration_ms = (time.monotonic() - t0) * 1000

    return {
        "status": "complete",
        "personas": results["personas"],
        "overall_avg": results["overall_avg"],
        "rating": results["rating"],
        "markdown": "\n".join(md_lines),
        "duration_ms": round(duration_ms, 1),
    }


@wrap_handler_errors
def wfs_pulse_handler(project_path: Path, args: dict[str, Any]) -> str:
    """Workflow Friction Score — per-persona dashboard-to-workflow friction."""
    result = pulse_wfs_impl(
        project_path,
        persona_filter=args.get("persona"),
    )
    return json.dumps(result, indent=2)


# ---------------------------------------------------------------------------
# WFS computation engine
# ---------------------------------------------------------------------------


def compute_wfs(
    appspec: AppSpec,
    *,
    persona_filter: str | None = None,
) -> dict[str, Any]:
    """Compute Workflow Friction Scores from an AppSpec.

    Returns a dict with per-persona breakdowns and an overall average.
    """
    # Build lookup indexes
    workspace_map: dict[str, Any] = {ws.name: ws for ws in appspec.workspaces}
    surface_map: dict[str, Any] = {s.name: s for s in appspec.surfaces}
    # Entity names that appear in each workspace's regions
    workspace_entities: dict[str, set[str]] = {}
    for ws in appspec.workspaces:
        entities: set[str] = set()
        for region in ws.regions:
            if region.source:
                entities.add(region.source)
            for src in region.sources or []:
                entities.add(src)
        workspace_entities[ws.name] = entities

    personas = appspec.personas
    stories = appspec.stories

    persona_results: list[dict[str, Any]] = []
    all_scores: list[float] = []

    for persona in personas:
        pid = persona.id
        if persona_filter and pid.lower() != persona_filter.lower():
            # Also try partial match
            plabel = getattr(persona, "label", "") or ""
            if (
                persona_filter.lower() not in pid.lower()
                and persona_filter.lower() not in plabel.lower()
            ):
                continue

        # Get persona's workspace
        ws_name = persona.default_workspace or ""
        workspace = workspace_map.get(ws_name)

        # Get stories for this persona
        persona_stories = [s for s in stories if s.actor.lower() == pid.lower()]
        if not persona_stories:
            # Try label match
            plabel = getattr(persona, "label", "") or pid
            persona_stories = [s for s in stories if s.actor.lower() == plabel.lower()]

        story_scores: list[dict[str, Any]] = []
        for story in persona_stories:
            score = _score_story_friction(
                story,
                workspace,
                workspace_entities,
                surface_map,
            )
            story_scores.append(score)
            all_scores.append(score["wfs"])

        avg_wfs = (
            round(sum(s["wfs"] for s in story_scores) / len(story_scores), 2)
            if story_scores
            else 0.0
        )

        persona_results.append(
            {
                "persona": pid,
                "label": getattr(persona, "label", "") or pid,
                "workspace": ws_name,
                "avg_wfs": avg_wfs,
                "rating": _wfs_rating(avg_wfs),
                "stories": story_scores,
            }
        )

    overall_avg = round(sum(all_scores) / len(all_scores), 2) if all_scores else 0.0

    return {
        "personas": persona_results,
        "overall_avg": overall_avg,
        "rating": _wfs_rating(overall_avg),
    }


def _score_story_friction(
    story: Any,
    workspace: Any | None,
    workspace_entities: dict[str, set[str]],
    surface_map: dict[str, Any],
) -> dict[str, Any]:
    """Score a single story's friction from the persona's workspace.

    Returns dict with wfs score, factor breakdown, and explanation.
    """
    scope_entities = set(story.scope or [])

    # --- Visibility (V): Is the story's entity on the dashboard? ---
    visibility = 2  # default: not linked anywhere
    discovery = 2  # default: no region at all
    ambiguity = 1  # default: unfiltered
    clicks = 2  # default: requires navigation

    if workspace is None:
        # No workspace assigned — maximum friction
        wfs = clicks + (visibility * 0.5) + (discovery * 1.5) + (ambiguity * 0.75)
        return {
            "story_id": story.story_id,
            "title": story.title,
            "wfs": round(wfs, 2),
            "rating": _wfs_rating(wfs),
            "factors": {"C": clicks, "V": visibility, "D": discovery, "A": ambiguity},
            "note": "No workspace assigned to persona",
        }

    ws_entities = workspace_entities.get(workspace.name, set())

    # Check if any scope entity appears in workspace regions
    matching_regions: list[Any] = []
    for region in workspace.regions:
        region_entities: set[str] = set()
        if region.source:
            region_entities.add(region.source)
        for src in region.sources or []:
            region_entities.add(src)

        if region_entities & scope_entities:
            matching_regions.append(region)

    if matching_regions:
        visibility = 0  # Entity is on the dashboard
        discovery = 1  # Region exists but need to check action links

        # Check if any matching region has an action link
        for region in matching_regions:
            if region.action:
                # Action points to a surface — check it exists
                action_surface = surface_map.get(region.action)
                if action_surface:
                    discovery = 0  # Clear action link present
                    clicks = 1  # One click from dashboard
                    break
            # Multi-source regions with sources also count
            if region.sources:
                discovery = 0
                clicks = 1
                break

        if discovery == 1:
            clicks = 2  # Must navigate away from dashboard

        # Check filter (ambiguity)
        for region in matching_regions:
            if region.filter is not None:
                ambiguity = 0  # Filtered — actionable items are isolated
                break
            # group_by or sort also reduce ambiguity
            if region.group_by or region.sort:
                ambiguity = 0
                break
    elif scope_entities & ws_entities:
        # Entity is on workspace but not in a matching region context
        visibility = 1
    # else: visibility stays 2 (not on dashboard)

    wfs = clicks + (visibility * 0.5) + (discovery * 1.5) + (ambiguity * 0.75)

    return {
        "story_id": story.story_id,
        "title": story.title,
        "wfs": round(wfs, 2),
        "rating": _wfs_rating(wfs),
        "factors": {"C": clicks, "V": visibility, "D": discovery, "A": ambiguity},
    }


def _wfs_rating(score: float) -> str:
    """Classify a WFS score into a human-readable rating."""
    if score <= 2:
        return "excellent"
    if score <= 4:
        return "acceptable"
    return "needs_work"


def _render_wfs_markdown(results: dict[str, Any]) -> list[str]:
    """Render WFS results as markdown lines."""
    md: list[str] = ["Workflow Friction Score", ""]
    md.append(f"Overall: {results['overall_avg']} ({results['rating']})")
    md.append("")

    for p in results["personas"]:
        md.append(f"  {p['label']} (workspace: {p['workspace'] or 'none'})")
        md.append(f"    Average WFS: {p['avg_wfs']} ({p['rating']})")
        for s in p["stories"]:
            factors = s["factors"]
            badge = (
                "ok"
                if s["rating"] == "excellent"
                else ".."
                if s["rating"] == "acceptable"
                else "!!"
            )
            md.append(
                f"    [{badge}] {s['title']}  "
                f"WFS={s['wfs']}  C={factors['C']} V={factors['V']} D={factors['D']} A={factors['A']}"
            )
            if s.get("note"):
                md.append(f"         {s['note']}")
        md.append("")

    return md


# ---------------------------------------------------------------------------
# Data collectors — each calls one handler and returns a parsed dict.
# ---------------------------------------------------------------------------


_COLLECTOR_TIMEOUT: float = DEFAULT_STEP_TIMEOUT


def _safe_result(fut: Any, name: str) -> dict[str, Any]:
    """Extract a future's result with timeout protection."""
    try:
        result: dict[str, Any] = fut.result(timeout=_COLLECTOR_TIMEOUT)
        return result
    except TimeoutError:
        logger.warning("Pulse collector '%s' timed out", name)
        return {"error": f"{name} timed out after {_COLLECTOR_TIMEOUT:.0f}s"}
    except Exception as e:
        logger.warning("Pulse collector '%s' failed: %s", name, e)
        return {"error": str(e)}


def _collect_all_parallel(
    project_path: Path,
    business_context: str | None = None,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
    """Run all five pulse collectors in parallel with per-collector timeout.

    Returns (pipeline, stories, coherence, policy, compliance) dicts.
    Any collector that times out or errors returns {"error": "<message>"}.
    """
    collectors: dict[str, tuple[Any, tuple[Any, ...]]] = {
        "pipeline": (_collect_pipeline, (project_path,)),
        "stories": (_collect_stories, (project_path,)),
        "coherence": (_collect_coherence, (project_path, business_context)),
        "policy": (_collect_policy, (project_path,)),
        "compliance": (_collect_compliance, (project_path,)),
    }

    results: dict[str, dict[str, Any]] = {}

    with ThreadPoolExecutor(max_workers=5, thread_name_prefix="pulse") as pool:
        futures = {pool.submit(fn, *args): name for name, (fn, args) in collectors.items()}
        for fut in as_completed(futures, timeout=_COLLECTOR_TIMEOUT * 2):
            name = futures[fut]
            try:
                results[name] = fut.result(timeout=_COLLECTOR_TIMEOUT)
            except TimeoutError:
                logger.warning("Pulse collector '%s' timed out", name)
                results[name] = {"error": f"{name} timed out after {_COLLECTOR_TIMEOUT:.0f}s"}
            except Exception as e:
                logger.warning("Pulse collector '%s' failed: %s", name, e)
                results[name] = {"error": str(e)}

    # Fill any missing collectors (shouldn't happen, but be safe)
    for name in collectors:
        if name not in results:
            results[name] = {"error": f"{name} did not complete"}

    return (
        results["pipeline"],
        results["stories"],
        results["coherence"],
        results["policy"],
        results["compliance"],
    )


def _collect_pipeline(project_path: Path) -> dict[str, Any]:
    from .pipeline import run_pipeline_handler

    raw = run_pipeline_handler(project_path, {"summary": True})
    result: dict[str, Any] = json.loads(raw)
    return result


def _collect_stories(project_path: Path) -> dict[str, Any]:
    from .process import stories_coverage_handler

    raw = stories_coverage_handler(project_path, {})
    result: dict[str, Any] = json.loads(raw)
    return result


def _collect_coherence(project_path: Path, business_context: str | None) -> dict[str, Any]:
    from .sitespec import coherence_handler

    coh_args: dict[str, Any] = {}
    if business_context:
        coh_args["business_context"] = business_context
    raw = coherence_handler(project_path, coh_args)
    result: dict[str, Any] = json.loads(raw)
    return result


def _collect_policy(project_path: Path) -> dict[str, Any]:
    from .policy import handle_policy

    raw = handle_policy(project_path, {"operation": "coverage"})
    result: dict[str, Any] = json.loads(raw)
    return result


def _collect_compliance(project_path: Path) -> dict[str, Any]:
    from dazzle.mcp.event_first_tools import handle_infer_compliance

    raw = handle_infer_compliance({}, project_path)
    result: dict[str, Any] = json.loads(raw)
    return result


def _collect_story_list(project_path: Path) -> dict[str, Any]:
    """Fetch the story list (with actor/persona info) for persona filtering."""
    from .stories import get_stories_handler

    raw = get_stories_handler(project_path, {"status_filter": "accepted"})
    result: dict[str, Any] = json.loads(raw)
    return result


# ---------------------------------------------------------------------------
# Metric synthesis
# ---------------------------------------------------------------------------

_RADAR_AXES = ("quality", "coverage", "content", "security", "compliance", "ux")

_AXIS_LABELS = {
    "quality": "Quality & Testing",
    "coverage": "Feature Completion",
    "content": "Site Content",
    "security": "Security & Access",
    "compliance": "Compliance",
    "ux": "User Experience",
}


def _compute_radar(
    pipeline: dict[str, Any],
    stories: dict[str, Any],
    coherence: dict[str, Any],
    policy: dict[str, Any],
    compliance: dict[str, Any],
) -> dict[str, float]:
    """Compute 0-100 scores on six axes."""
    return {
        "quality": _quality_score(pipeline),
        "coverage": _coverage_score(stories),
        "content": _content_score(coherence),
        "security": _security_score(policy),
        "compliance": _compliance_score(compliance),
        "ux": _ux_score(coherence),
    }


def _quality_score(pipeline: dict[str, Any]) -> float:
    """Quality axis: pipeline pass rate."""
    summary = pipeline.get("summary", {})
    total = int(summary.get("total_steps", 0))
    passed = int(summary.get("passed", 0))
    if total == 0:
        return 0.0
    return float(round((passed / total) * 100, 1))


def _coverage_score(stories: dict[str, Any]) -> float:
    """Coverage axis: story implementation coverage."""
    if "error" in stories:
        return 0.0
    return float(round(float(stories.get("coverage_percent", 0.0)), 1))


def _content_score(coherence: dict[str, Any]) -> float:
    """Content axis: site coherence score (how complete the content is)."""
    if "error" in coherence:
        return 0.0
    return round(float(coherence.get("score", 0)), 1)


def _security_score(policy: dict[str, Any]) -> float:
    """Security axis: proportion of entity/persona combos with a deliberate posture.

    Default-deny is a *secure* posture — it means the app blocks access unless
    explicitly granted.  We therefore count explicit allow + explicit deny +
    default deny as "covered".  Only combinations with *no* rule at all are gaps.
    """
    summary = policy.get("summary", {})
    total = int(summary.get("total_combinations", 0))
    if total == 0:
        return 0.0
    allow = int(summary.get("allow", 0))
    explicit_deny = int(summary.get("explicit_deny", 0))
    default_deny = int(summary.get("default_deny", 0))
    covered = allow + explicit_deny + default_deny
    return float(round((covered / total) * 100, 1))


def _compliance_score(compliance: dict[str, Any]) -> float:
    """Compliance axis: sensitive-field detection coverage.

    If no sensitive fields are detected, score is 100 (nothing to protect).
    Otherwise, score is based on how many recommended frameworks are addressable.
    """
    if "error" in compliance:
        return 0.0
    pii = len(compliance.get("pii_fields", []))
    financial = len(compliance.get("financial_fields", []))
    health = len(compliance.get("health_fields", []))
    total_sensitive = pii + financial + health
    if total_sensitive == 0:
        return 100.0  # Nothing to protect
    # Presence of recommended frameworks shows awareness
    frameworks = compliance.get("recommended_frameworks", [])
    suggestions = compliance.get("classification_suggestions", [])
    # Score: proportion of sensitive fields with classification suggestions
    if total_sensitive > 0 and suggestions:
        return round(min(len(suggestions) / total_sensitive, 1.0) * 100, 1)
    # Frameworks detected but no suggestions yet → partial credit
    if frameworks:
        return 50.0
    return 0.0


def _ux_score(coherence: dict[str, Any]) -> float:
    """UX axis: coherence minus errors/warnings penalty."""
    if "error" in coherence:
        return 0.0
    base = float(coherence.get("score", 0))
    errors = int(coherence.get("error_count", 0))
    warnings = int(coherence.get("warning_count", 0))
    # Each error costs 10 pts, each warning costs 3 pts (floor at 0)
    penalty = errors * 10 + warnings * 3
    return float(round(max(base - penalty, 0.0), 1))


def _composite_health(radar: dict[str, float]) -> float:
    """Weighted composite of the six radar axes."""
    weights = {
        "quality": 0.20,
        "coverage": 0.25,
        "content": 0.15,
        "security": 0.15,
        "compliance": 0.10,
        "ux": 0.15,
    }
    total = sum(radar.get(axis, 0) * w for axis, w in weights.items())
    return round(total, 1)


# ---------------------------------------------------------------------------
# Founder-facing narrative helpers
# ---------------------------------------------------------------------------


def _extract_project_name(pipeline: dict[str, Any], project_path: Path) -> str:
    """Best-effort project name from pipeline or path."""
    # Try to find the validate step which has entity/surface counts
    for step in pipeline.get("steps", []):
        if step.get("operation") == "dsl(validate)":
            result = step.get("result", step.get("metrics", {}))
            name = result.get("project_path", "")
            if name:
                return Path(name).name
    return project_path.name


def _founder_decisions(
    stories: dict[str, Any],
    coherence: dict[str, Any],
    policy: dict[str, Any],
) -> list[dict[str, str]]:
    """Identify choices that need the founder's input."""
    decisions: list[dict[str, str]] = []

    # Partial stories need prioritisation
    partial = stories.get("partial", 0)
    uncovered = stories.get("uncovered", 0)
    if partial + uncovered > 0:
        decisions.append(
            {
                "category": "priority",
                "question": (
                    f"{partial + uncovered} customer stories need work — "
                    "which ones matter most for launch?"
                ),
            }
        )

    # Coherence errors need direction
    errors = coherence.get("error_count", 0)
    if errors > 0:
        decisions.append(
            {
                "category": "content",
                "question": (
                    f"Your site has {errors} broken element(s) — fix now or launch anyway?"
                ),
            }
        )

    # Default-deny permissions may need review
    summary = policy.get("summary", {})
    default_deny = summary.get("default_deny", 0)
    if default_deny > 10:
        decisions.append(
            {
                "category": "security",
                "question": (
                    f"{default_deny} permission combinations have no explicit rule — "
                    "review or accept secure defaults?"
                ),
            }
        )

    return decisions


def _recent_wins(
    pipeline: dict[str, Any],
    stories: dict[str, Any],
    policy: dict[str, Any],
) -> list[str]:
    """Celebrate what's working well."""
    wins: list[str] = []

    # Pipeline passing
    summary = pipeline.get("summary", {})
    passed = summary.get("passed", 0)
    total = summary.get("total_steps", 0)
    if total > 0 and passed == total:
        wins.append(f"All {total} quality checks passing")

    # Story coverage milestones
    coverage = stories.get("coverage_percent", 0)
    covered = stories.get("covered", 0)
    total_stories = stories.get("total_stories", 0)
    if coverage >= 80:
        wins.append(f"{covered} of {total_stories} customer journeys working ({coverage:.0f}%)")
    elif covered > 0:
        wins.append(f"{covered} customer journeys implemented so far")

    # Security coverage
    policy_summary = policy.get("summary", {})
    allow = policy_summary.get("allow", 0)
    if allow > 0:
        wins.append(f"Access rules configured ({allow} permission grants)")

    return wins


def _framework_blockers(
    pipeline: dict[str, Any],
    coherence: dict[str, Any],
) -> list[str]:
    """Issues that are framework problems, not founder problems."""
    blockers: list[str] = []

    # Pipeline errors
    for step in pipeline.get("steps", []):
        if step.get("status") == "error":
            error = step.get("error", "unknown error")
            blockers.append(f"{step.get('operation', 'step')}: {error}")

    # Coherence issues that look like rendering bugs
    for issue in coherence.get("issues", []):
        if issue.get("severity") == "error":
            msg = issue.get("message", "")
            if "render" in msg.lower() or "template" in msg.lower():
                blockers.append(msg)

    return blockers[:5]  # Cap at 5


def _derive_milestones(
    pipeline: dict[str, Any],
    stories: dict[str, Any],
    story_list: dict[str, Any],
    policy: dict[str, Any],
    coherence: dict[str, Any],
) -> list[dict[str, Any]]:
    """Derive milestones from current project state.

    Each milestone is {label, done, detail (optional)}.
    Order: spec → validation → stories → security → content → launch.
    """
    milestones: list[dict[str, Any]] = []

    # 1. Spec written (entities exist)
    has_entities = False
    for step in pipeline.get("steps", []):
        if step.get("operation") == "dsl(validate)" and step.get("status") == "passed":
            metrics = step.get("metrics", step.get("result", {}))
            entity_count = metrics.get("entities", 0)
            surface_count = metrics.get("surfaces", 0)
            has_entities = entity_count > 0
            milestones.append(
                {
                    "label": "Spec written",
                    "done": True,
                    "detail": f"{entity_count} entities, {surface_count} surfaces",
                }
            )
            break
    if not milestones:
        milestones.append({"label": "Spec written", "done": False, "detail": None})

    # 2. Validation passing
    pipeline_status = pipeline.get("status", "")
    summary = pipeline.get("summary", {})
    passed = int(summary.get("passed", 0))
    total = int(summary.get("total_steps", 0))
    if pipeline_status == "passed" and total > 0:
        milestones.append(
            {"label": "All quality checks passing", "done": True, "detail": f"{passed}/{total}"}
        )
    elif total > 0:
        milestones.append(
            {
                "label": "All quality checks passing",
                "done": False,
                "detail": f"{passed} of {total} passing",
            }
        )
    else:
        milestones.append({"label": "All quality checks passing", "done": False, "detail": None})

    # 3. Stories accepted
    all_stories: list[dict[str, Any]] = story_list.get("stories", [])
    story_count = story_list.get("count", len(all_stories))
    if story_count > 0:
        milestones.append(
            {"label": "Customer stories defined", "done": True, "detail": f"{story_count} stories"}
        )
    else:
        milestones.append({"label": "Customer stories defined", "done": False, "detail": None})

    # 4. Story coverage > 50%
    coverage_pct = stories.get("coverage_percent", 0)
    covered = stories.get("covered", 0)
    total_stories = stories.get("total_stories", 0)
    half_covered = coverage_pct >= 50
    milestones.append(
        {
            "label": "Half of stories working",
            "done": half_covered,
            "detail": f"{covered}/{total_stories} covered ({coverage_pct:.0f}%)"
            if total_stories > 0
            else None,
        }
    )

    # 5. Security configured
    policy_summary = policy.get("summary", {})
    allow = int(policy_summary.get("allow", 0))
    has_security = allow > 0 and has_entities
    milestones.append(
        {
            "label": "Access rules configured",
            "done": has_security,
            "detail": f"{allow} permission grants" if has_security else None,
        }
    )

    # 6. Site content coherent
    coherence_score = coherence.get("score", 0)
    coherence_errors = coherence.get("error_count", 0)
    content_ready = coherence_score >= 70 and coherence_errors == 0
    milestones.append(
        {
            "label": "Site content ready",
            "done": content_ready,
            "detail": f"coherence {coherence_score}%"
            if not isinstance(coherence_score, str)
            else None,
        }
    )

    # 7. All stories working
    all_covered = coverage_pct >= 100 and total_stories > 0
    milestones.append(
        {
            "label": "All stories working",
            "done": all_covered,
            "detail": f"{covered}/{total_stories}" if total_stories > 0 else None,
        }
    )

    # 8. Launch ready (all above done)
    all_done = all(m["done"] for m in milestones)
    milestones.append({"label": "Launch ready", "done": all_done, "detail": None})

    return milestones


def _decision_queue(
    stories: dict[str, Any],
    coherence: dict[str, Any],
    policy: dict[str, Any],
    pipeline: dict[str, Any],
) -> list[dict[str, Any]]:
    """Build a decision queue with multiple-choice options for each decision."""
    decisions: list[dict[str, Any]] = []

    # 1. Partial/uncovered stories need prioritisation
    partial = int(stories.get("partial", 0))
    uncovered = int(stories.get("uncovered", 0))
    total_needing_work = partial + uncovered
    if total_needing_work > 0:
        decisions.append(
            {
                "category": "priority",
                "question": f"{total_needing_work} customer stories need work — what's the focus?",
                "options": [
                    {
                        "key": "A",
                        "label": f"Finish partial stories first ({partial} stories)",
                    },
                    {
                        "key": "B",
                        "label": f"Start uncovered stories ({uncovered} stories)",
                    },
                    {
                        "key": "C",
                        "label": "Both in parallel",
                    },
                ],
            }
        )

    # 2. Coherence errors
    errors = int(coherence.get("error_count", 0))
    warnings = int(coherence.get("warning_count", 0))
    if errors > 0:
        decisions.append(
            {
                "category": "content",
                "question": f"Your site has {errors} broken element(s) and {warnings} warning(s)",
                "options": [
                    {"key": "A", "label": "Fix all issues before launch"},
                    {"key": "B", "label": "Fix errors only, accept warnings"},
                    {"key": "C", "label": "Launch as-is, fix later"},
                ],
            }
        )

    # 3. Default-deny permissions
    policy_summary = policy.get("summary", {})
    default_deny = int(policy_summary.get("default_deny", 0))
    if default_deny > 10:
        decisions.append(
            {
                "category": "security",
                "question": f"{default_deny} permission combinations have no explicit rule",
                "options": [
                    {"key": "A", "label": "Review and set explicit rules"},
                    {"key": "B", "label": "Accept secure defaults (deny by default)"},
                    {"key": "C", "label": "Open access for now, tighten later"},
                ],
            }
        )

    # 4. Pipeline failures
    failed = int(pipeline.get("summary", {}).get("failed", 0))
    if failed > 0:
        decisions.append(
            {
                "category": "quality",
                "question": f"{failed} quality check(s) failing",
                "options": [
                    {"key": "A", "label": "Fix all failures before continuing"},
                    {"key": "B", "label": "Skip failing checks and continue"},
                ],
            }
        )

    return decisions


# ---------------------------------------------------------------------------
# Markdown renderer
# ---------------------------------------------------------------------------


def _render_markdown(
    *,
    project_name: str,
    health_score: float,
    radar: dict[str, float],
    needs_input: list[dict[str, str]],
    recent_wins: list[str],
    blockers: list[str],
    stories_data: dict[str, Any],
    pipeline_data: dict[str, Any],
) -> str:
    """Render the founder-facing markdown briefing."""
    # Headline
    lines = [f"{project_name} — {health_score:.0f}% Launch Ready", ""]

    # Status summary (plain-language translation of each axis)
    lines.append("**This Session**")
    lines.append("")
    lines.extend(_render_status_lines(radar, stories_data, pipeline_data))
    lines.append("")

    # Decisions
    if needs_input:
        lines.append("**What Needs Your Input**")
        lines.append("")
        for i, decision in enumerate(needs_input, 1):
            lines.append(f"  {i}. {decision['question']}")
        lines.append("")

    # Blockers
    if blockers:
        lines.append("**Blocked (framework issues)**")
        lines.append("")
        for b in blockers:
            lines.append(f"  - {b}")
        lines.append("")

    # Wins
    if recent_wins:
        lines.append("**Recent Wins**")
        lines.append("")
        for w in recent_wins:
            lines.append(f"  + {w}")
        lines.append("")

    # Radar summary
    lines.append("**Readiness Radar**")
    lines.append("")
    for axis in _RADAR_AXES:
        score = radar.get(axis, 0)
        bar = _progress_bar(score)
        label = axis.replace("_", " ").title()
        lines.append(f"  {label:12s} {bar} {score:.0f}%")
    lines.append("")

    return "\n".join(lines)


def _render_status_lines(
    radar: dict[str, float],
    stories: dict[str, Any],
    pipeline: dict[str, Any],
) -> list[str]:
    """Translate radar scores into plain-language status lines."""
    lines: list[str] = []

    # Stories
    covered = stories.get("covered", 0)
    total = stories.get("total_stories", 0)
    pct = stories.get("coverage_percent", 0)
    if total > 0:
        lines.append(f"  Customer journeys: {covered} of {total} working ({pct:.0f}%)")

    # Quality
    summary = pipeline.get("summary", {})
    passed = summary.get("passed", 0)
    total_steps = summary.get("total_steps", 0)
    failed = summary.get("failed", 0)
    if total_steps > 0:
        if failed == 0:
            lines.append(f"  Quality: {passed} checks passing, 0 failing")
        else:
            lines.append(f"  Quality: {passed} of {total_steps} checks passing ({failed} failing)")

    # Security
    if radar.get("security", 0) > 0:
        lines.append(f"  Security: {radar['security']:.0f}% of permissions explicitly configured")

    # Content
    if radar.get("content", 0) > 0:
        lines.append(f"  Site content: {radar['content']:.0f}% coherence score")

    return lines


def _progress_bar(value: float, width: int = 20) -> str:
    """Render an ASCII progress bar."""
    filled = int(round(value / 100 * width))
    return "[" + "#" * filled + "-" * (width - filled) + "]"
