# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 sol pbc

"""Home app - Pulse landing page."""

from __future__ import annotations

import json
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import quote

logger = logging.getLogger(__name__)

from flask import Blueprint, current_app, jsonify

from solstone.apps.home.connections import build_connections_card
from solstone.apps.home.health_glance import build_health_glance
from solstone.apps.home.needs_you import classify_needs_you, needs_dedup_key
from solstone.apps.home.owner_voice import build_owner_voice_needs
from solstone.convey import state
from solstone.convey.backlog_source import load_backlog_source
from solstone.convey.bridge import get_cached_state
from solstone.convey.shell_data import _resolve_attention
from solstone.convey.utils import DATE_RE, format_date, relative_time
from solstone.think.awareness import get_current
from solstone.think.brain_health import build_brain_snapshot
from solstone.think.briefing import (
    briefing_meeting_count,
    briefing_needs_items,
    load_briefing,
    render_briefing_sections,
)
from solstone.think.capture_health import get_capture_health
from solstone.think.day_accumulator import read_latest
from solstone.think.facets import get_enabled_facets, get_facets
from solstone.think.pipeline_health import summarize_pipeline_day
from solstone.think.steward import read_steward_health, read_steward_summary
from solstone.think.talent import morning_briefing_path
from solstone.think.utils import day_path, get_journal

# Briefing phase thresholds
BRIEFING_MORNING_END_HOUR = 10
BRIEFING_LATENESS_THRESHOLD_HOURS = 2
BRIEFING_EOD_HOUR = 20

home_bp = Blueprint(
    "app:home",
    __name__,
    url_prefix="/app/home",
    static_folder="static",
    static_url_path="/static",
)

_FIRST_WEEK_FRAMING = "most of what i keep becomes useful after about a week, once your journal has enough of your days in it to show patterns. for now, here's what's already happening:"


def _today() -> str:
    return datetime.now().strftime("%Y%m%d")


def _yesterday() -> str:
    return (datetime.now().astimezone() - timedelta(days=1)).strftime("%Y%m%d")


def _count_journal_age_days(today: str) -> int:
    chronicle_dir = Path(get_journal()) / "chronicle"
    if not chronicle_dir.is_dir():
        return 0

    earliest: datetime | None = None
    for child in chronicle_dir.iterdir():
        if not child.is_dir() or not child.name.isdigit() or len(child.name) != 8:
            continue
        try:
            day = datetime.strptime(child.name, "%Y%m%d")
        except ValueError:
            continue
        if earliest is None or day < earliest:
            earliest = day

    if earliest is None:
        return 0

    try:
        today_dt = datetime.strptime(today, "%Y%m%d")
    except ValueError:
        return 0
    return max(0, (today_dt - earliest).days)


def _load_latest_weekly_reflection() -> dict[str, str] | None:
    reflections_dir = Path(get_journal()) / "reflections" / "weekly"
    if not reflections_dir.is_dir():
        return None

    days = sorted(
        path.stem
        for path in reflections_dir.glob("*.md")
        if path.is_file() and DATE_RE.fullmatch(path.stem)
    )
    if not days:
        return None

    day = days[-1]
    return {
        "day": day,
        "label": format_date(day),
        "url": f"/app/reflections/{day}",
    }


def _load_flow_md(today: str) -> tuple[str | None, float | None]:
    """Load today's flow.md content and mtime. Returns (content, mtime) or (None, None)."""
    try:
        # flow.md is no longer produced by any current talent (the flow talent was
        # removed); this fallback is presently inert and will light up only if a
        # flow producer returns. Resolve via day_path so the path can't rot.
        flow_path = day_path(today, create=False) / "talents" / "flow.md"
        if flow_path.exists():
            return flow_path.read_text(), flow_path.stat().st_mtime
    except Exception:
        logger.warning("home: failed to load flow.md", exc_info=True)
    return None, None


def _load_pulse_narrative(today: str) -> tuple[str | None, str | None, list[str]]:
    """Load today's latest pulse record as narrative content."""
    try:
        record = read_latest(today, "pulse", lookback_days=0)
        if not record:
            return None, None, []

        full_details = record.get("full_details")
        if not isinstance(full_details, str) or not full_details.strip():
            return None, None, []

        needs = [str(n) for n in record.get("needs_you", []) if str(n).strip()]
        updated_at = None
        ts = record.get("ts")
        if isinstance(ts, (int, float)):
            updated_at = datetime.fromtimestamp(ts / 1000).strftime("%H:%M")
        return full_details, updated_at, needs
    except Exception:
        logger.warning("home: failed to load pulse record", exc_info=True)
        return None, None, []


def _compute_briefing_phase(
    segment_count: int, hour: int, briefing_exists: bool
) -> str:
    """Compute briefing display phase from current time and activity."""
    if hour >= BRIEFING_EOD_HOUR:
        return "eod"
    if not briefing_exists and hour < BRIEFING_MORNING_END_HOUR:
        return "pending"
    if briefing_exists and (segment_count == 0 or hour < BRIEFING_MORNING_END_HOUR):
        return "morning"
    if briefing_exists and segment_count > 0:
        return "active"
    return "eod"


def _briefing_lateness_state(now: datetime, phase: str) -> dict[str, Any]:
    """Return late-state metadata for a pending morning briefing."""
    due_at = now.replace(
        hour=BRIEFING_MORNING_END_HOUR,
        minute=0,
        second=0,
        microsecond=0,
    )
    late_hours = max(0, int((now - due_at).total_seconds() // 3600))
    is_late = (
        phase == "pending"
        and now.hour > BRIEFING_MORNING_END_HOUR + BRIEFING_LATENESS_THRESHOLD_HOURS
    )
    return {"late": is_late, "late_hours": late_hours if is_late else 0}


def _briefing_summary(
    briefing: dict | None, sections: dict[str, str], needs_count: int
) -> str:
    """Generate a short collapsed summary for the briefing card."""
    meeting_count = briefing_meeting_count(briefing or {})

    if meeting_count or needs_count:
        meeting_label = "meeting" if meeting_count == 1 else "meetings"
        needs_label = "item needs" if needs_count == 1 else "items need"
        return (
            f"morning briefing — {meeting_count} {meeting_label}, "
            f"{needs_count} {needs_label} attention"
        )

    for content in sections.values():
        for line in content.splitlines():
            stripped = line.strip()
            if not stripped:
                continue
            stripped = stripped.removeprefix("- ").strip()
            if len(stripped) > 58:
                stripped = stripped[:55].rstrip() + "..."
            return f"morning briefing — {stripped}"
    return "morning briefing"


def _load_stats(today: str) -> dict[str, Any]:
    """Load per-day stats.json. Returns empty dict if missing."""
    try:
        journal = Path(get_journal())
        stats_path = journal / today / "stats.json"
        if stats_path.exists():
            return json.loads(stats_path.read_text())
    except Exception:
        logger.warning("home: failed to load stats", exc_info=True)
    return {}


def _load_yesterday_stats(yesterday: str) -> dict[str, Any] | None:
    try:
        stats_path = Path(get_journal()) / "chronicle" / yesterday / "stats.json"
        if not stats_path.exists():
            return None
        return json.loads(stats_path.read_text(encoding="utf-8"))
    except Exception:
        logger.warning("home: failed to load yesterday stats", exc_info=True)
        return None


def _load_yesterday_pipeline_summary(yesterday: str) -> dict[str, Any]:
    return summarize_pipeline_day(yesterday)


def _collect_anticipated_activities(today: str) -> list[dict[str, Any]]:
    """Collect anticipated activities across all facets."""
    from solstone.think.activities import load_activity_records

    try:
        anticipated_activities = []
        for facet_name in get_facets():
            for record in load_activity_records(facet_name, today):
                if record.get("source") != "anticipated":
                    continue

                participants = []
                for entry in record.get("participation", []):
                    if not isinstance(entry, dict) or entry.get("role") != "attendee":
                        continue
                    name = str(entry.get("name") or "").strip()
                    if name:
                        participants.append(name)

                anticipated_activities.append(
                    {
                        "title": record.get("title", ""),
                        "start": record.get("start") or "",
                        "end": record.get("end") or "",
                        "facet": facet_name,
                        "occurred": False,
                        "participants": participants,
                    }
                )
        return anticipated_activities
    except Exception:
        logger.warning("home: failed to collect anticipated activities", exc_info=True)
        return []


def _collect_activities(today: str) -> list[dict[str, Any]]:
    """Collect recent activities across all facets, last ~4 hours."""
    from solstone.think.activities import load_activity_records

    activities = []
    try:
        facets = get_facets()
    except Exception:
        logger.warning("home: failed to get facets for activities", exc_info=True)
        return []

    now = datetime.now()
    cutoff_ts = (now - timedelta(hours=4)).timestamp() * 1000  # ms

    for facet_name in facets:
        records = load_activity_records(facet_name, today)
        for record in records:
            if record.get("source") == "anticipated":
                continue
            created_at = record.get("created_at", 0)
            if created_at < cutoff_ts:
                continue
            # Convert ms timestamp to HH:MM for display
            try:
                dt = datetime.fromtimestamp(created_at / 1000)
                record["display_time"] = dt.strftime("%H:%M")
            except (OSError, ValueError):
                record["display_time"] = ""
            record["facet"] = facet_name
            activities.append(record)

    activities.sort(key=lambda a: a.get("created_at", 0), reverse=True)
    return activities


def _normalize_activity_title(record: dict[str, Any]) -> str:
    title = str(record.get("description") or "").strip()
    if title:
        return title
    activity = str(record.get("activity") or "").strip().replace("_", " ")
    if activity:
        return activity.title()
    return "untitled activity"


def _collect_top_activities_yesterday(yesterday: str) -> list[dict[str, Any]]:
    from solstone.think.activities import (
        estimate_duration_minutes,
        load_activity_records,
    )

    activities = []
    try:
        facets = get_enabled_facets()
    except Exception:
        logger.warning(
            "home: failed to get enabled facets for yesterday activities",
            exc_info=True,
        )
        return []

    for facet_name in facets:
        for record in load_activity_records(facet_name, yesterday):
            segments = record.get("segments", [])
            activities.append(
                {
                    **record,
                    "facet": facet_name,
                    "title": _normalize_activity_title(record),
                    "duration_minutes": estimate_duration_minutes(segments),
                }
            )

    activities.sort(
        key=lambda record: (
            -int(record.get("duration_minutes", 0)),
            record.get("title", "").lower(),
            record.get("facet", ""),
        )
    )
    return activities


def _top_heatmap_hours(stats_data: dict[str, Any]) -> list[int]:
    hours = stats_data.get("heatmap_data", {}).get("hours", {})
    ranked = []
    for hour, minutes in hours.items():
        try:
            hour_int = int(hour)
            minutes_value = float(minutes)
        except (TypeError, ValueError):
            continue
        if minutes_value <= 0:
            continue
        ranked.append((hour_int, minutes_value))

    ranked.sort(key=lambda item: (-item[1], item[0]))
    return [hour for hour, _minutes in ranked[:3]]


def _briefing_freshness(today: str) -> dict[str, Any]:
    briefing_path = morning_briefing_path(today)
    if not briefing_path.exists():
        return {"exists": False, "valid": False, "generated_label": None}

    briefing = load_briefing(today)
    if briefing is None:
        return {"exists": True, "valid": False, "generated_label": None}

    generated_label = None
    metadata = (
        briefing.get("metadata") if isinstance(briefing.get("metadata"), dict) else {}
    )
    generated = metadata.get("generated")
    if generated is not None:
        try:
            generated_dt = datetime.fromisoformat(str(generated))
            generated_label = generated_dt.astimezone().strftime("%-I:%M%p").lower()
        except Exception:
            generated_label = None

    return {"exists": True, "valid": True, "generated_label": generated_label}


def _newsletter_attempts_from_think_logs(yesterday: str) -> tuple[int, int]:
    """Count facet-newsletter attempts for ``yesterday`` from journal artifacts.

    Reads two paths whose shapes must stay in sync:
    - ``facets/*/news/{yesterday}.md``: one file per facet that successfully
      ran the newsletter talent; presence counts as a success.
    - ``chronicle/{yesterday}/health/*_daily.jsonl``: ``talent.fail`` records
      whose ``name == "facet_newsletter"`` count as failed attempts.

    If either path's filename pattern or event shape changes, this function
    will silently undercount; the daily-processing summary that consumes it
    will be wrong without raising. Update both readers in lockstep when
    moving either side.

    Returns ``(successful, total_attempted)`` where
    ``total_attempted == successful + failed``.
    """
    journal = Path(get_journal())
    successful = len(list(journal.glob(f"facets/*/news/{yesterday}.md")))

    failed = 0
    health_dir = journal / "chronicle" / yesterday / "health"
    if health_dir.is_dir():
        for path in sorted(health_dir.glob("*_daily.jsonl")):
            try:
                with path.open(encoding="utf-8") as handle:
                    for raw_line in handle:
                        line = raw_line.strip()
                        if not line:
                            continue
                        try:
                            record = json.loads(line)
                        except json.JSONDecodeError:
                            continue
                        if (
                            record.get("event") == "talent.fail"
                            and record.get("facet")
                            and record.get("name") == "facet_newsletter"
                        ):
                            failed += 1
            except OSError:
                logger.warning(
                    "home: failed to read newsletter think log %s",
                    path,
                    exc_info=True,
                )

    return successful, successful + failed


def _format_duration(total_minutes: float) -> str:
    rounded_minutes = int(round(total_minutes))
    if rounded_minutes < 60:
        return f"{rounded_minutes} min"

    rounded_hours = round(total_minutes / 60, 1)
    if float(rounded_hours).is_integer():
        hours_int = int(rounded_hours)
        return f"{hours_int} hour{'s' if hours_int != 1 else ''}"
    return f"{rounded_hours:.1f} hours"


def _format_hour_label(start_hour: int, end_hour: int) -> str:
    def render(hour: int, *, include_meridiem: bool) -> str:
        normalized = hour % 24
        display_hour = normalized % 12 or 12
        meridiem = "am" if normalized < 12 else "pm"
        return f"{display_hour}{meridiem}" if include_meridiem else str(display_hour)

    start_meridiem = "am" if start_hour % 24 < 12 else "pm"
    end_meridiem = "am" if end_hour % 24 < 12 else "pm"
    return (
        f"{render(start_hour, include_meridiem=start_meridiem != end_meridiem)}-"
        f"{render(end_hour, include_meridiem=True)}"
    )


def _join_phrases(parts: list[str]) -> str:
    if not parts:
        return ""
    if len(parts) == 1:
        return parts[0]
    if len(parts) == 2:
        return f"{parts[0]} and {parts[1]}"
    return ", ".join(parts[:-1]) + f", and {parts[-1]}"


def _format_activity_label(activity: dict[str, Any]) -> str:
    return (
        f"I took notes on {activity.get('title', 'untitled activity')} "
        f"for {_format_duration(activity.get('duration_minutes', 0))} in "
        f"{activity.get('facet', 'unknown')}."
    )


def _format_newsletter_summary(successful: int, attempted: int) -> str:
    if attempted == 0:
        return "I didn't produce any facet newsletters."
    if attempted > successful:
        return (
            f"I wrote {successful} of {attempted} newsletter"
            f"{'s' if attempted != 1 else ''}."
        )
    return f"I wrote {successful} newsletter{'s' if successful != 1 else ''}."


def _format_processing_summary(
    mode: str,
    successful_newsletters: int,
    attempted_newsletters: int,
    briefing: dict[str, Any],
) -> str:
    if mode == "degraded":
        if attempted_newsletters == 0 and successful_newsletters == 0:
            return (
                "I didn't produce any facet newsletters, and some overnight "
                "processing didn't finish."
            )
        if attempted_newsletters > successful_newsletters:
            return (
                f"I wrote {successful_newsletters} of {attempted_newsletters} "
                "newsletters, but some overnight processing didn't finish."
            )
        return (
            f"I wrote {successful_newsletters} newsletter"
            f"{'s' if successful_newsletters != 1 else ''}, but some overnight "
            "processing didn't finish."
        )

    actions = []
    if successful_newsletters > 0:
        actions.append(
            f"wrote {successful_newsletters} newsletter"
            f"{'s' if successful_newsletters != 1 else ''}"
        )
    if briefing.get("valid"):
        actions.append("prepared your morning briefing")
    if not actions:
        return _format_newsletter_summary(successful_newsletters, attempted_newsletters)
    return f"I {_join_phrases(actions)}."


def _format_heatmap_summary(stats_data: dict[str, Any]) -> str | None:
    hours = sorted(_top_heatmap_hours(stats_data))
    if not hours:
        return None

    ranges = []
    range_start = hours[0]
    range_end = hours[0] + 1
    for hour in hours[1:]:
        if hour == range_end:
            range_end += 1
            continue
        ranges.append(_format_hour_label(range_start, range_end))
        range_start = hour
        range_end = hour + 1
    ranges.append(_format_hour_label(range_start, range_end))
    return "your busiest stretches were " + " · ".join(ranges) + "."


def _format_gap_links(
    pipeline_summary: dict[str, Any],
    briefing: dict[str, Any],
    yesterday_day: str,
    today: str,
) -> list[dict[str, str]]:
    links: list[dict[str, str]] = []
    anomalies = pipeline_summary.get("anomalies", [])
    has_daily = any(
        anomaly.get("kind") == "daily_agents_missing" for anomaly in anomalies
    )
    has_activity = any(
        anomaly.get("kind") == "activity_agents_missing" for anomaly in anomalies
    )
    failure_anomalies = [
        anomaly for anomaly in anomalies if anomaly.get("kind") == "talent_failure"
    ]

    if has_daily:
        links.append(
            {
                "text": "I didn't finish the full overnight review.",
                "href": f"/app/sol/{yesterday_day}",
            }
        )
    if has_activity:
        links.append(
            {
                "text": "I didn't finish writing all of yesterday's notes.",
                "href": f"/app/sol/{yesterday_day}",
            }
        )

    grouped_failures: dict[str, list[dict[str, Any]]] = {}
    for anomaly in failure_anomalies:
        name = str(anomaly.get("name") or "").strip()
        if not name:
            continue
        grouped_failures.setdefault(name, []).append(anomaly)

    named_failures = []
    for name, failures in grouped_failures.items():
        count = len(failures)
        label = name.replace("_", " ")
        request_lost = all(
            failure.get("state") == "request_lost" for failure in failures
        )
        if count == 1:
            if request_lost:
                text = f"The {label} run couldn't start."
            else:
                text = f"The {label} run didn't finish."
        elif request_lost:
            text = f"{count} {label} runs couldn't start."
        else:
            text = f"{count} {label} runs didn't finish."

        use_id = str(failures[0].get("use_id") or "").strip()
        anchor = quote(name, safe="")
        if count == 1 and use_id:
            anchor += f"/{quote(use_id, safe='')}"
        named_failures.append(
            {
                "text": text,
                "href": f"/app/sol/{yesterday_day}#{anchor}",
            }
        )

    links.extend(named_failures)
    talents = pipeline_summary.get("talents") or {}
    if talents.get("failed_list_truncated"):
        n_more = int(talents.get("outstanding_failed", 0) or 0) - len(failure_anomalies)
        if n_more > 0:
            links.append(
                {
                    "text": f"…and {n_more} more didn't finish.",
                    "href": f"/app/sol/{yesterday_day}",
                }
            )

    if failure_anomalies and not has_daily and not has_activity and not named_failures:
        links.append(
            {
                "text": "Some of my overnight work didn't finish.",
                "href": f"/app/sol/{yesterday_day}",
            }
        )

    if not briefing.get("valid"):
        links.append(
            {
                "text": "I didn't prepare your morning briefing overnight.",
                "href": f"/app/sol/{today}#morning_briefing",
            }
        )
    return links


def _summarize_yesterday_processing(
    yesterday: str, journal_age_days: int
) -> dict[str, Any] | None:
    stats_data = _load_yesterday_stats(yesterday)
    if stats_data is None or journal_age_days == 0:
        return None

    stats = stats_data.get("stats", {})
    transcript_seconds = float(stats.get("transcript_duration", 0) or 0)
    transcript_segments = int(stats.get("transcript_segments", 0) or 0)
    facet_data = stats_data.get("facet_data", {})
    has_facet_activity = any(
        float(facet.get("minutes", 0) or 0) > 0 or int(facet.get("count", 0) or 0) > 0
        for facet in facet_data.values()
    )

    activities = _collect_top_activities_yesterday(yesterday)
    if (
        transcript_seconds <= 0
        and transcript_segments <= 0
        and not has_facet_activity
        and not activities
    ):
        return None

    pipeline_summary = _load_yesterday_pipeline_summary(yesterday)
    today = _today()
    briefing = _briefing_freshness(today)
    successful_newsletters, attempted_newsletters = (
        _newsletter_attempts_from_think_logs(yesterday)
    )

    is_sparse = (
        (transcript_seconds > 0 or transcript_segments > 0)
        and not has_facet_activity
        and not activities
    )

    status_reasons = []
    if attempted_newsletters > successful_newsletters:
        status_reasons.append("newsletter_partial")
    if pipeline_summary.get("status") != "healthy":
        status_reasons.append("pipeline_warning")
    if not briefing.get("valid"):
        status_reasons.append("briefing_missing")

    if is_sparse:
        mode = "sparse"
    elif status_reasons:
        mode = "degraded"
    else:
        mode = "healthy"

    first_week_framing = (
        _FIRST_WEEK_FRAMING if journal_age_days <= 7 and mode != "sparse" else None
    )

    if mode == "sparse":
        summary_line = f"i took in {_format_duration(transcript_seconds / 60)} yesterday and kept it in your journal."
        return {
            "title": "Yesterday's processing",
            "mode": mode,
            "default_collapsed": False,
            "first_week_framing": None,
            "summary_line": summary_line,
            "details": None,
            "gap_links": [],
            "sparse_lines": [
                "I didn't produce any facet newsletters.",
                "There wasn't much else to process.",
            ],
            "status_reasons": status_reasons,
        }

    details = []
    gap_links: list[dict[str, str]] = []
    if mode == "degraded":
        gap_links = _format_gap_links(pipeline_summary, briefing, yesterday, today)

    details.append(
        _format_newsletter_summary(successful_newsletters, attempted_newsletters)
    )
    if briefing.get("valid"):
        details.append(
            "I prepared your morning briefing"
            + (
                f" at {briefing['generated_label']}."
                if briefing.get("generated_label")
                else "."
            )
        )

    heatmap_summary = _format_heatmap_summary(stats_data)
    if heatmap_summary:
        details.append(heatmap_summary)

    for activity in activities[:2]:
        details.append(_format_activity_label(activity))

    default_collapsed = mode == "healthy" and journal_age_days >= 8
    return {
        "title": (
            "⚠ Yesterday's processing"
            if mode == "degraded"
            else "Yesterday's processing"
        ),
        "mode": mode,
        "default_collapsed": default_collapsed,
        "first_week_framing": first_week_framing,
        "gap_links": gap_links,
        "summary_line": _format_processing_summary(
            mode,
            successful_newsletters,
            attempted_newsletters,
            briefing,
        ),
        "details": details,
        "sparse_lines": None,
        "status_reasons": status_reasons,
    }


def _build_pulse_context() -> dict[str, Any]:
    """Build the full Pulse page context."""
    today = _today()
    yesterday = _yesterday()
    now = datetime.now()
    journal_age_days = _count_journal_age_days(today)

    capture_health = get_capture_health()
    cached = get_cached_state()
    last_observe_ts = cached.get("last_observe_ts")
    attention = _resolve_attention(get_current())

    stats_data = _load_stats(today)
    stats = stats_data.get("stats", {})
    segment_count = stats.get("transcript_segments", 0)
    facet_data = stats_data.get("facet_data", {})

    flow_content, flow_mtime = _load_flow_md(today)
    flow_updated_at = None
    if flow_mtime:
        flow_updated_at = datetime.fromtimestamp(flow_mtime).strftime("%H:%M")

    # Try today's pulse record as primary narrative, fall back to flow.md
    pulse_content, pulse_time, pulse_needs = _load_pulse_narrative(today)
    if pulse_content:
        narrative_content = pulse_content
        narrative_source = "pulse"
        narrative_header = "pulse"
        narrative_updated_at = pulse_time or flow_updated_at
    else:
        narrative_content = flow_content
        narrative_source = "flow"
        narrative_header = "today's flow"
        narrative_updated_at = flow_updated_at
        pulse_needs = []

    anticipated_activities = _collect_anticipated_activities(today)
    activities = _collect_activities(today)
    latest_weekly_reflection = _load_latest_weekly_reflection()

    last_observe_relative = None
    if last_observe_ts:
        try:
            delta = now - datetime.fromtimestamp(last_observe_ts)
            last_observe_relative = f"{relative_time(delta.total_seconds())} ago"
        except Exception:
            logger.warning(
                "home: failed to compute last_observe_relative", exc_info=True
            )

    # Briefing card
    briefing = load_briefing(today)
    briefing_sections = render_briefing_sections(briefing) if briefing else {}
    briefing_meta = briefing.get("metadata") if briefing else None
    briefing_needs = briefing_needs_items(briefing) if briefing else []
    briefing_exists = bool(briefing_sections)
    briefing_phase = _compute_briefing_phase(segment_count, now.hour, briefing_exists)
    briefing_lateness = _briefing_lateness_state(now, briefing_phase)
    show_welcome = (
        narrative_content is None
        and not anticipated_activities
        and not activities
        and not briefing_exists
        and not attention
        and not pulse_needs
        and not latest_weekly_reflection
    )

    try:
        owner_voice_needs = build_owner_voice_needs(today)
    except Exception:
        logger.warning("home: failed to build owner voice needs", exc_info=True)
        owner_voice_needs = []
    pulse_needs = [*owner_voice_needs, *pulse_needs]

    # Section summaries for collapsed state
    narrative_summary = ""
    if narrative_content:
        narrative_summary = narrative_header
        if narrative_updated_at:
            narrative_summary += f" — updated {narrative_updated_at}"

    today_summary_parts = []
    if anticipated_activities:
        n = len(anticipated_activities)
        today_summary_parts.append(f"{n} anticipated activit{'ies' if n != 1 else 'y'}")
    if activities:
        n = len(activities)
        today_summary_parts.append(f"{n} {'activities' if n != 1 else 'activity'}")
    today_summary = ", ".join(today_summary_parts)

    # Dedup needs by stable source identity (normalized-text fallback for legacy
    # plain-string items) so one underlying source renders once across the pulse
    # "needs you" (attention + pulse needs) and briefing "needs attention" surfaces.
    attention_key = needs_dedup_key(attention) if attention else None
    needs_you_keys: set[str] = set()
    if attention_key is not None:
        needs_you_keys.add(attention_key)
    deduped_pulse_needs: list[Any] = []
    for need in pulse_needs:
        key = needs_dedup_key(need)
        if key in needs_you_keys:
            continue
        needs_you_keys.add(key)
        deduped_pulse_needs.append(need)

    needs_you_items = classify_needs_you(attention, deduped_pulse_needs)
    needs_count = len(needs_you_items)
    needs_summary = ""
    if needs_count:
        needs_summary = (
            f"{needs_count} item{'s' if needs_count != 1 else ''} "
            f"need{'s' if needs_count == 1 else ''} attention"
        )

    briefing_needs_deduped = []
    briefing_needs_shared_count = 0
    seen_keys = set(needs_you_keys)
    for item in briefing_needs:
        key = needs_dedup_key(item)
        if key in needs_you_keys:
            briefing_needs_shared_count += 1
            continue
        if key in seen_keys:
            continue
        seen_keys.add(key)
        briefing_needs_deduped.append(item)

    briefing_needs_badge = None
    if briefing_needs_shared_count > 0:
        s = "" if briefing_needs_shared_count == 1 else "s"
        briefing_needs_badge = (
            f"{briefing_needs_shared_count} item{s} also in Pulse needs"
        )

    briefing_summary = None
    if briefing_phase == "active":
        briefing_summary = _briefing_summary(
            briefing, briefing_sections, len(briefing_needs_deduped)
        )
    briefing_needs_deduped_text = [item["text"] for item in briefing_needs_deduped]

    pipeline_status = read_steward_health()
    if pipeline_status is not None:
        # Enrich the deterministic warning signal with the lite generate talent's
        # human-friendly summary (headline + sentence + a closed-enum action) when
        # one is available. Falls back to the raw bullet (message) if not.
        summary = read_steward_summary()
        if summary:
            pipeline_status = {**pipeline_status, **summary}
    brain = build_brain_snapshot(datetime.now(timezone.utc), surface="home")
    backlog_source = load_backlog_source(state.journal_root)
    health_glance = build_health_glance(
        capture_health,
        pipeline_status,
        last_observe_relative,
        backlog=backlog_source,
        brain=brain,
    )

    yesterday_processing = _summarize_yesterday_processing(yesterday, journal_age_days)

    return {
        "today": today,
        "now": now,
        "health_glance": health_glance,
        "attention": attention,
        "pipeline_status": pipeline_status,
        "segment_count": segment_count,
        "facet_data": facet_data,
        "narrative_content": narrative_content,
        "narrative_updated_at": narrative_updated_at,
        "narrative_source": narrative_source,
        "narrative_header": narrative_header,
        "pulse_needs": pulse_needs,
        "flow_content": flow_content,
        "flow_updated_at": flow_updated_at,
        "anticipated_activities": anticipated_activities,
        "activities": activities,
        "needs_you_items": [item.to_dict() for item in needs_you_items],
        "briefing_sections": briefing_sections,
        "briefing_meta": briefing_meta,
        "briefing_phase": briefing_phase,
        "briefing_lateness": briefing_lateness,
        "briefing_exists": briefing_exists,
        "briefing_summary": briefing_summary,
        "briefing_needs_deduped": briefing_needs_deduped_text,
        "briefing_needs_shared_count": briefing_needs_shared_count,
        "briefing_needs_badge": briefing_needs_badge,
        "latest_weekly_reflection": latest_weekly_reflection,
        "yesterday_processing": yesterday_processing,
        "connections": build_connections_card(),
        "show_welcome": show_welcome,
        "journal_age_days": journal_age_days,
        "home_state": "welcome" if show_welcome else "active",
        "welcome_framing": (
            _FIRST_WEEK_FRAMING if (show_welcome and journal_age_days <= 7) else None
        ),
        "narrative_summary": narrative_summary,
        "today_summary": today_summary,
        "needs_summary": needs_summary,
    }


@home_bp.route("/")
def index():
    return current_app.send_static_file("shell.html")


@home_bp.route("/api/pulse")
def api_pulse():
    """Aggregated JSON for client-side refresh after WebSocket events."""
    ctx = _build_pulse_context()
    attention = ctx.get("attention")
    if attention:
        ctx["attention"] = {
            "placeholder_text": attention.placeholder_text,
            "context_lines": attention.context_lines,
        }
    ctx.pop("show_welcome", None)
    ctx["now"] = ctx["now"].isoformat()
    return jsonify(ctx)


@home_bp.route("/api/briefing")
def api_briefing():
    """Briefing-specific JSON for WebSocket-triggered refresh."""
    ctx = _build_pulse_context()
    meta = ctx.get("briefing_meta")
    return jsonify(
        {
            "exists": ctx["briefing_exists"],
            "phase": ctx["briefing_phase"],
            "summary": ctx["briefing_summary"],
            "meta": meta,
            "sections": ctx["briefing_sections"],
            "needs_deduped": ctx["briefing_needs_deduped"],
            "needs_shared_count": ctx["briefing_needs_shared_count"],
            "needs_badge": ctx["briefing_needs_badge"],
        }
    )
