"""JSON tile payloads for bundled dashboard plugins. All times UTC ISO in `fetched_at`."""

from __future__ import annotations

import json
import os
import re
import time
from datetime import date, datetime, timezone
from urllib.parse import quote
from urllib.request import Request, urlopen

from dashboard_bundled.sanitize import safe_clip, safe_http_url, sanitize_plain_text

try:
    import ssl

    _SSL = ssl.create_default_context()
    try:
        import certifi

        _SSL.load_verify_locations(certifi.where())
    except Exception:
        pass
except Exception:
    _SSL = None


def _utc_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _parse_dt_iso(s: str) -> datetime | None:
    if not s or not isinstance(s, str):
        return None
    try:
        x = s.strip().replace("Z", "+00:00")
        dt = datetime.fromisoformat(x)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt.astimezone()
    except Exception:
        return None


def _relative_until(from_dt: datetime | None) -> str:
    if not from_dt:
        return ""
    now = datetime.now(from_dt.tzinfo)
    sec = (from_dt - now).total_seconds()
    if sec < -3600:
        return ""
    if sec <= 90:
        return "soon"
    if sec < 3600:
        m = max(1, int(sec // 60))
        return f"in {m}m"
    if sec < 86400:
        h = max(1, int(sec // 3600))
        return f"in {h}h"
    return from_dt.strftime("%b %d")


def _parse_news_date(raw: object) -> datetime | None:
    s = str(raw or "").strip()
    if not s:
        return None
    if s.isdigit() and len(s) >= 10:
        try:
            ts = int(s[:10])
            return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone()
        except Exception:
            pass
    return _parse_dt_iso(s)


def _age_short(from_dt: datetime | None) -> str:
    if not from_dt:
        return ""
    now = datetime.now(from_dt.tzinfo)
    sec = max(0, (now - from_dt).total_seconds())
    if sec < 90:
        return "just now"
    if sec < 3600:
        return f"{int(sec // 60)}m ago"
    if sec < 86400:
        return f"{int(sec // 3600)}h ago"
    return f"{int(sec // 86400)}d ago"


def build_payload(
    *,
    kind: str,
    state: str,
    title: str,
    data: dict,
    cache_ttl_seconds: int,
    message: str = "",
) -> dict:
    out = {
        "version": 1,
        "kind": kind,
        "state": state,
        "title": safe_clip(title, 120),
        "fetched_at": _utc_iso(),
        "cache_ttl_seconds": int(cache_ttl_seconds),
        "message": safe_clip(message, 520) if message else "",
    }
    out.update(data)
    return out


def feed_system_vitals() -> dict:
    try:
        from dashboard_bundled.system_vitals import get_collector

        data = get_collector().sample()
        return build_payload(
            kind="system_vitals",
            state="ok",
            title="System",
            cache_ttl_seconds=5,
            data=data,
        )
    except ImportError:
        return build_payload(
            kind="system_vitals",
            state="error",
            title="System",
            cache_ttl_seconds=5,
            message="System monitoring needs the psutil package. Re-run setup to update the backend environment.",
            data={},
        )
    except Exception as e:
        return build_payload(
            kind="system_vitals",
            state="error",
            title="System",
            cache_ttl_seconds=5,
            message=safe_clip(str(e), 200),
            data={},
        )


def feed_clock() -> dict:
    try:
        from zoneinfo import ZoneInfo

        tz_name = ""
        try:
            from core.config import get_config

            tz_name = str((get_config().get("dashboard") or {}).get("timezone") or "").strip()
            if not tz_name:
                tz_name = str((get_config().get("general") or {}).get("timezone") or "").strip()
        except Exception:
            pass
        tz = ZoneInfo(tz_name) if tz_name else None
        now = datetime.now(tz) if tz else datetime.now().astimezone()
    except Exception:
        now = datetime.now().astimezone()
    label = now.strftime("%I:%M %p").lstrip("0") if now.hour % 12 != 0 else now.strftime("%I:%M %p")
    date_s = now.strftime("%A, %b %d")
    tz_lbl = str(now.tzinfo) if getattr(now, "tzinfo", None) else ""
    return build_payload(
        kind="clock",
        state="ok",
        title="Time",
        cache_ttl_seconds=60,
        data={
            "time_display": safe_clip(label, 32),
            "date_display": safe_clip(date_s, 80),
            "timezone_label": safe_clip(tz_lbl, 64),
            "server_time": now.isoformat(),
        },
    )


def _friendly_weather_upstream_err(raw: str) -> str:
    """Avoid raw urllib 'HTTP Error 502: Bad Gateway' in the dashboard; keep user-facing copy short."""
    s = (raw or "").strip()
    if not s:
        return "Weather unavailable. Try again shortly."
    low = s.lower()
    if (
        "502" in s
        or "503" in s
        or "504" in s
        or "bad gateway" in low
        or "gateway timeout" in low
        or "http error" in low
    ):
        return "Weather service was busy. Try again in a moment."
    if "429" in s or "too many requests" in low:
        return "Weather service rate-limited. Try again shortly."
    if "timed out" in low or "timeout" in low:
        return "Weather request timed out. Check your network and try again."
    return safe_clip(s, 200)


def _forecast_days_from_open_meteo_daily(
    daily: dict,
    *,
    wmo_labels: dict[int, str],
) -> list[dict]:
    """Build 7-day rows for the dashboard tile from Open-Meteo ``daily`` block."""
    times = daily.get("time") or []
    codes = daily.get("weather_code") or []
    tmax = daily.get("temperature_2m_max") or []
    tmin = daily.get("temperature_2m_min") or []
    pmax = daily.get("precipitation_probability_max") or []
    today = date.today()
    out: list[dict] = []
    # Do not require precipitation_probability_max length (optional in some responses).
    n = min(7, len(times), len(codes), len(tmax), len(tmin))
    for i in range(n):
        tstr = times[i]
        if not tstr or not isinstance(tstr, str):
            continue
        day = None
        try:
            day = datetime.strptime(tstr[:10], "%Y-%m-%d").date()
        except ValueError:
            continue
        delta = (day - today).days
        if delta == 0:
            label = "Today"
        elif delta == 1:
            label = "Tomorrow"
        else:
            label = day.strftime("%a %d %b")
        wc = codes[i]
        try:
            wci = int(wc) if wc is not None else None
        except (TypeError, ValueError):
            wci = None
        hi = tmax[i]
        lo = tmin[i]
        pop = pmax[i] if i < len(pmax) else None
        try:
            hi_f = float(hi) if hi is not None else None
        except (TypeError, ValueError):
            hi_f = None
        try:
            lo_f = float(lo) if lo is not None else None
        except (TypeError, ValueError):
            lo_f = None
        try:
            pop_f = float(pop) if pop is not None else None
        except (TypeError, ValueError):
            pop_f = None
        desc = ""
        if wci is not None:
            desc = wmo_labels.get(wci, "")
        out.append(
            {
                "date_iso": safe_clip(tstr[:10], 12),
                "label": safe_clip(label, 28),
                "weather_code": wci,
                "conditions_short": safe_clip(desc, 40),
                "high": hi_f,
                "low": lo_f,
                "precipitation_probability_max": pop_f,
            }
        )
    return out


_WEATHER_FEED_CACHE: dict | None = None
_WEATHER_FEED_CACHE_AT: float = 0.0
# Share Open-Meteo responses across the Weather tile, dashboard_set_weather_location, and agent prompt injection.
_WEATHER_FEED_TTL_SECONDS = 90.0


def clear_weather_feed_cache() -> None:
    """Invalidate cached `feed_weather` payload (e.g. after [weather] default_location changes)."""
    global _WEATHER_FEED_CACHE, _WEATHER_FEED_CACHE_AT
    _WEATHER_FEED_CACHE = None
    _WEATHER_FEED_CACHE_AT = 0.0


def _feed_weather_build() -> dict:
    try:
        from tools import weather_tools as wt

        raw_config_loc = (wt._default_location() or "").strip()  # noqa: SLF001
        cfg_for_ui = safe_clip(raw_config_loc, 200)
        loc = raw_config_loc
        if not loc:
            return build_payload(
                kind="weather",
                state="empty",
                title="Forecast",
                cache_ttl_seconds=1200,
                message="Double-click or right-click the line above the greeting to set your location.",
                data={"config_default_location": cfg_for_ui},
            )
        lat, lon, display_name, geo_err = wt._geocode(loc)  # noqa: SLF001
        if geo_err:
            return build_payload(
                kind="weather",
                state="error",
                title="Forecast",
                cache_ttl_seconds=1200,
                message=_friendly_weather_upstream_err(geo_err),
                data={"config_default_location": cfg_for_ui},
            )
        temp_unit, wind_unit = wt._weather_units()  # noqa: SLF001
        url = wt.DASHBOARD_WEATHER_AND_FORECAST_URL_TEMPLATE.format(  # noqa: SLF001
            lat=lat, lon=lon, temp_unit=temp_unit, wind_unit=wind_unit
        )
        ok, data, err = wt._fetch_json(url)  # noqa: SLF001
        if not ok:
            return build_payload(
                kind="weather",
                state="error",
                title="Forecast",
                cache_ttl_seconds=1200,
                message=_friendly_weather_upstream_err(err or "weather failed"),
                data={"config_default_location": cfg_for_ui},
            )
        cur = (data or {}).get("current") or {}
        code = cur.get("weather_code")
        wmo = wt._WMO_CODES.get(int(code), f"code {code}") if code is not None else "unknown"  # noqa: SLF001
        temp = cur.get("temperature_2m")
        dew = cur.get("dew_point_2m")
        temp_sym = "°F" if temp_unit == "fahrenheit" else "°C"
        line = f"{temp}{temp_sym}, {wmo}" if temp is not None else wmo
        daily_block = (data or {}).get("daily") or {}
        wmo_map = getattr(wt, "_WMO_CODES", {}) or {}
        forecast = _forecast_days_from_open_meteo_daily(daily_block, wmo_labels=wmo_map)
        return build_payload(
            kind="weather",
            state="ok",
            title="Forecast",
            cache_ttl_seconds=1200,
            data={
                "config_default_location": cfg_for_ui,
                "location": safe_clip(display_name, 120),
                "summary": safe_clip(line, 200),
                "temperature": temp,
                "dew_point": dew,
                "unit_suffix": temp_sym,
                "conditions": safe_clip(str(wmo), 80),
                "weather_code": int(code) if code is not None else None,
                "forecast": forecast,
            },
        )
    except Exception as e:
        snap = ""
        try:
            from tools import weather_tools as wt

            snap = safe_clip((wt._default_location() or "").strip(), 200)  # noqa: SLF001
        except Exception:
            pass
        return build_payload(
            kind="weather",
            state="error",
            title="Forecast",
            cache_ttl_seconds=1200,
            message=_friendly_weather_upstream_err(str(e)),
            data={"config_default_location": snap},
        )


def feed_weather() -> dict:
    global _WEATHER_FEED_CACHE, _WEATHER_FEED_CACHE_AT
    now = time.monotonic()
    if _WEATHER_FEED_CACHE is not None and (now - _WEATHER_FEED_CACHE_AT) < _WEATHER_FEED_TTL_SECONDS:
        return _WEATHER_FEED_CACHE
    out = _feed_weather_build()
    _WEATHER_FEED_CACHE = out
    _WEATHER_FEED_CACHE_AT = now
    return out


def format_weather_context_for_agent_from_payload(
    p: dict, *, max_chars: int = 1800, cache_ttl_note_seconds: int | None = 90
) -> str:
    """Plain-text block for the system prompt: same [weather] source as the dashboard tile."""
    st = p.get("state")
    if st == "empty":
        return (
            "No [weather] default_location is set; the dashboard Weather tile is empty until the user "
            "sets a place from the hero area."
        )
    if st == "error":
        msg = (p.get("message") or "").strip() or "Weather unavailable"
        return f"Weather data unavailable: {safe_clip(msg, 500)}"
    if st != "ok":
        return "Weather data unavailable (unexpected state)."

    loc = (p.get("location") or "").strip() or (p.get("config_default_location") or "").strip() or "unknown"
    summary = (p.get("summary") or "").strip()
    unit = (p.get("unit_suffix") or "°F").strip() or "°F"
    fetched = (p.get("fetched_at") or "").strip()
    lines: list[str] = [f"Location: {loc}"]
    if fetched:
        lines.append(f"Fetched (ISO UTC): {fetched}")
    if summary:
        lines.append(f"Current: {summary}")
    if cache_ttl_note_seconds and cache_ttl_note_seconds > 0:
        lines.append(
            f"Server may serve this for up to ~{cache_ttl_note_seconds}s without refetch; "
            "use get_weather for another place or for the freshest value."
        )
    fc = p.get("forecast")
    if isinstance(fc, list) and fc:
        lines.append("7-day outlook (high / low):")
        for item in fc[:8]:
            if not isinstance(item, dict):
                continue
            label = (item.get("label") or item.get("date_iso") or "?").strip()
            hi = item.get("high")
            lo = item.get("low")
            cond = (item.get("conditions_short") or "").strip()
            pop = item.get("precipitation_probability_max")
            bits: list[str] = [label]
            if hi is not None and lo is not None:
                bits.append(f"high {hi}{unit} / low {lo}{unit}")
            elif hi is not None:
                bits.append(f"high {hi}{unit}")
            if cond:
                bits.append(cond)
            line = " — ".join(bits)
            if pop is not None:
                line += f" (precip {pop}%)"
            lines.append("  " + line)
    text = "\n".join(lines)
    return safe_clip(text, max_chars)


def dashboard_weather_context_for_agent() -> str:
    """Build agent-visible weather/forecast from the same feed as the dashboard (respects `feed_weather` cache)."""
    try:
        p = feed_weather()
        return format_weather_context_for_agent_from_payload(
            p, max_chars=1800, cache_ttl_note_seconds=int(_WEATHER_FEED_TTL_SECONDS)
        )
    except Exception:
        return "Weather context unavailable (internal error)."


def _fmt_rate_kbps(kbps: object) -> str:
    try:
        v = float(kbps)  # type: ignore[arg-type]
    except (TypeError, ValueError):
        return "?"
    if v >= 1024.0:
        return f"{v / 1024.0:.1f} MB/s"
    return f"{v:.0f} KB/s"


def format_system_vitals_context_from_sample(s: dict, *, max_chars: int = 1600) -> str:
    """Plain-text vitals block for the system prompt: same sample as the dashboard System strip."""
    lines: list[str] = []
    age = s.get("sample_age_seconds")
    if isinstance(age, (int, float)):
        lines.append(f"Sampled {age:.0f}s ago (System strip refreshes ~5s while the dashboard is open).")

    head: list[str] = []
    cpu = s.get("cpu_percent")
    if cpu is not None:
        head.append(f"CPU {cpu}%")
    gpu = s.get("gpu_percent")
    if gpu is not None:
        head.append(f"GPU {gpu}%")
    mem_p = s.get("memory_percent")
    mem_used = s.get("memory_used_gb")
    mem_total = s.get("memory_total_gb")
    if mem_p is not None:
        if mem_used is not None and mem_total is not None:
            head.append(f"RAM {mem_used}/{mem_total} GB ({mem_p}%)")
        else:
            head.append(f"RAM {mem_p}%")
    disk_p = s.get("disk_percent")
    disk_used = s.get("disk_used_gb")
    disk_total = s.get("disk_total_gb")
    if disk_p is not None:
        if disk_used is not None and disk_total is not None:
            head.append(f"Disk {disk_used}/{disk_total} GB ({disk_p}%)")
        else:
            head.append(f"Disk {disk_p}%")
    if head:
        lines.append(" · ".join(head))

    down = s.get("network_down_kbps")
    up = s.get("network_up_kbps")
    if down is not None or up is not None:
        lines.append(f"Network: down {_fmt_rate_kbps(down)} / up {_fmt_rate_kbps(up)} (whole machine)")

    procs = s.get("processes")
    if isinstance(procs, list) and procs:
        rows: list[str] = []
        for p in procs[:5]:
            if not isinstance(p, dict):
                continue
            name = str(p.get("name") or "").strip()
            if not name:
                continue
            rows.append(f"{name} (cpu {p.get('cpu_percent', '?')}%, mem {p.get('memory_percent', '?')}%)")
        if rows:
            lines.append("Top processes by CPU: " + "; ".join(rows))

    return safe_clip("\n".join(lines), max_chars)


def dashboard_system_vitals_context_for_agent() -> str:
    """Agent-visible machine vitals from the same collector as the dashboard System strip.

    Read-only: never takes a fresh sample (that would perturb the strip's
    network-rate baseline). Empty string when no recent sample exists.
    """
    try:
        from dashboard_bundled.system_vitals import get_collector

        s = get_collector().last_sample(max_age_seconds=120.0)
        if not s:
            return ""
        return format_system_vitals_context_from_sample(s)
    except Exception:
        return ""


def feed_calendar() -> dict:
    try:
        from tools._macos_bridge import bridge_request_json, get_bridge_config

        cfg, err = get_bridge_config()
        if not cfg:
            return build_payload(
                kind="calendar",
                state="needs_permission",
                title="Calendar",
                cache_ttl_seconds=900,
                message=safe_clip(err or "Bridge unavailable", 200),
                data={"events": []},
            )
        status, obj, e2 = bridge_request_json(cfg, "/calendar/events?hours=24", method="GET", timeout=12.0)
        if e2:
            return build_payload(
                kind="calendar",
                state="error",
                title="Calendar",
                cache_ttl_seconds=900,
                message=_bridge_http_message(status, obj if isinstance(obj, dict) else None, e2),
                data={"events": []},
            )
        if status != 200:
            return build_payload(
                kind="calendar",
                state="error",
                title="Calendar",
                cache_ttl_seconds=900,
                message=_bridge_http_message(status, obj if isinstance(obj, dict) else None, None),
                data={"events": []},
            )
        obj = obj or {}
        if not obj.get("success"):
            base_msg = str(obj.get("message") or "Calendar unavailable")
            ek = obj.get("eventkit_calendar_authorization")
            bid = obj.get("bundle_identifier")
            extras: list[str] = []
            if ek:
                extras.append(str(ek))
            if bid:
                extras.append(str(bid))
            if extras:
                base_msg = f"{base_msg} [{' · '.join(extras)}]"
            fmt = obj.get("calendar_bridge_error_format")
            if fmt is not None:
                base_msg = f"{base_msg} [format:{fmt}]"
            return build_payload(
                kind="calendar",
                state="needs_permission",
                title="Calendar",
                cache_ttl_seconds=900,
                message=safe_clip(base_msg, 480),
                data={"events": []},
            )
        raw_events = obj.get("events") or []
        events: list[dict] = []
        for ev in raw_events[:12]:
            if not isinstance(ev, dict):
                continue
            eid = str(ev.get("start") or ev.get("title") or "") + str(ev.get("title") or "")[:32]
            events.append(
                {
                    "id": safe_clip(eid, 128),
                    "title": safe_clip(str(ev.get("title") or "(No title)"), 200),
                    "start": safe_clip(str(ev.get("start") or ""), 64),
                    "end": safe_clip(str(ev.get("end") or ""), 64),
                    "location": safe_clip(str(ev.get("location") or ""), 160),
                }
            )
        def _sort_key(ev: dict) -> float:
            d = _parse_dt_iso(str(ev.get("start") or ""))
            if d is None:
                return 1e30
            return d.timestamp()

        events.sort(key=_sort_key)
        for i, ev in enumerate(events):
            stt = _parse_dt_iso(str(ev.get("start") or ""))
            ev["relative_until"] = safe_clip(_relative_until(stt), 24)
            ev["is_next"] = i == 0
        st = "ok" if events else "empty"
        return build_payload(
            kind="calendar",
            state=st,
            title="Calendar",
            cache_ttl_seconds=900,
            data={"events": events},
        )
    except Exception as e:
        return build_payload(
            kind="calendar",
            state="error",
            title="Calendar",
            cache_ttl_seconds=900,
            message=safe_clip(str(e), 200),
            data={"events": []},
        )


def feed_reminders() -> dict:
    try:
        from tools._macos_bridge import bridge_request_json, get_bridge_config

        cfg, err = get_bridge_config()
        if not cfg:
            return build_payload(
                kind="reminders",
                state="needs_permission",
                title="Reminders",
                cache_ttl_seconds=600,
                message=safe_clip(err or "Bridge unavailable", 200),
                data={"items": []},
            )
        path = "/reminders/list?due_within_hours=48&incomplete=1"
        status, obj, e2 = bridge_request_json(cfg, path, method="GET", timeout=12.0)
        if e2:
            return build_payload(
                kind="reminders",
                state="error",
                title="Reminders",
                cache_ttl_seconds=600,
                message=_bridge_http_message(status, obj if isinstance(obj, dict) else None, e2),
                data={"items": []},
            )
        if status != 200:
            return build_payload(
                kind="reminders",
                state="error",
                title="Reminders",
                cache_ttl_seconds=600,
                message=_bridge_http_message(status, obj if isinstance(obj, dict) else None, None),
                data={"items": []},
            )
        obj = obj or {}
        if not obj.get("success"):
            return build_payload(
                kind="reminders",
                state="needs_permission",
                title="Reminders",
                cache_ttl_seconds=600,
                message=safe_clip(str(obj.get("message") or "Reminders unavailable"), 200),
                data={"items": []},
            )
        # Parse formatted markdown-ish lines from `formatted` for list rows
        lines = str(obj.get("formatted") or "").splitlines()
        items: list[dict] = []
        for i, ln in enumerate(lines[:20]):
            s = ln.strip().lstrip("-").strip()
            if not s or s.startswith("**"):
                continue
            slow = s.lower()
            urgency = "high" if any(k in slow for k in ("today", "tomorrow", "overdue")) else "normal"
            items.append(
                {
                    "id": safe_clip(f"r{i}", 64),
                    "text": safe_clip(s, 300),
                    "urgency": urgency,
                }
            )
            if len(items) >= 8:
                break
        st = "ok" if items else "empty"
        return build_payload(
            kind="reminders",
            state=st,
            title="Reminders",
            cache_ttl_seconds=600,
            data={"items": items},
        )
    except Exception as e:
        return build_payload(
            kind="reminders",
            state="error",
            title="Reminders",
            cache_ttl_seconds=600,
            message=safe_clip(str(e), 200),
            data={"items": []},
        )


def feed_memory_note() -> dict:
    try:
        from core.config import get_workspace_path

        root = get_workspace_path() or os.path.expanduser("~/.mugi/workspace")
        mem = os.path.join(root, "MEMORY.md")
        note_path = os.path.join(os.path.expanduser("~/.mugi"), "DASHBOARD_NOTE.md")
        text = ""
        src = "MEMORY.md"
        if os.path.isfile(note_path):
            try:
                with open(note_path, "r", encoding="utf-8", errors="replace") as f:
                    text = f.read(8000)
                src = "DASHBOARD_NOTE.md"
            except Exception:
                text = ""
        if not text.strip() and os.path.isfile(mem):
            try:
                from utils.memory_shard_layout import layout_is_sharded

                gen = os.path.join(root, "memory", "shards", "general.md")
                if layout_is_sharded(root) and os.path.isfile(gen):
                    with open(gen, "r", encoding="utf-8", errors="replace") as f:
                        text = f.read(8000)
                    src = "memory/shards/general.md"
                else:
                    with open(mem, "r", encoding="utf-8", errors="replace") as f:
                        text = f.read(8000)
            except Exception:
                try:
                    with open(mem, "r", encoding="utf-8", errors="replace") as f:
                        text = f.read(8000)
                except Exception:
                    text = ""
        text = sanitize_plain_text(text, max_len=1200)
        if not text.strip():
            return build_payload(
                kind="memory_note",
                state="empty",
                title="Note",
                cache_ttl_seconds=14400,
                message="No memory snippet yet.",
                data={"source": src, "preview": ""},
            )
        one_block = text.strip().split("\n\n")[0][:800]
        one_block = re.sub(r"(?m)^#{1,6}\s*", "", one_block)
        one_block = re.sub(r"\s+", " ", one_block).strip()
        return build_payload(
            kind="memory_note",
            state="ok",
            title="Memory",
            cache_ttl_seconds=14400,
            data={"source": safe_clip(src, 40), "preview": safe_clip(one_block, 800)},
        )
    except Exception as e:
        return build_payload(
            kind="memory_note",
            state="error",
            title="Memory",
            cache_ttl_seconds=14400,
            message=safe_clip(str(e), 200),
            data={"preview": ""},
        )


def _bridge_http_message(status: int, obj: dict | None, transport_err: str | None) -> str:
    if transport_err:
        return safe_clip(transport_err, 450)
    if isinstance(obj, dict):
        m = obj.get("message") or obj.get("error")
        if m:
            line = str(m)
            ek = obj.get("eventkit_calendar_authorization")
            bid = obj.get("bundle_identifier")
            extras: list[str] = []
            if ek:
                extras.append(str(ek))
            if bid:
                extras.append(str(bid))
            if extras:
                line = f"{line} [{', '.join(extras)}]"
            det = obj.get("calendar_system_detail")
            if isinstance(det, str) and det.strip():
                line = f"{line} ({det.strip()[:120]})"
            fmt = obj.get("calendar_bridge_error_format")
            if fmt is not None:
                line = f"{line} [format:{fmt}]"
            return safe_clip(line, 500)
    return safe_clip(f"HTTP {status}", 120)


# Legacy Stooq index aliases (configs may still carry them) → Yahoo symbols.
_YAHOO_SYMBOL_ALIASES = {
    "^SPX": "^GSPC",
    "^NDQ": "^IXIC",
    "^NDX": "^NDX",
    "^DJI": "^DJI",
}


def _normalize_market_symbol(sym: str) -> str:
    """Map configured (possibly Stooq-era) symbols to Yahoo Finance symbols."""
    s = re.sub(r"[^A-Za-z0-9.^=\-]", "", (sym or "").strip())[:16].upper()
    if s.endswith(".US"):
        s = s[:-3]
    return _YAHOO_SYMBOL_ALIASES.get(s, s)


def _yahoo_quote(sym: str, *, timeout: float = 5.0) -> tuple[str | None, str | None, str]:
    """One quote row from Yahoo's keyless v8 chart endpoint. Returns (line, err, trend)."""
    q = _normalize_market_symbol(sym)
    if not q:
        return None, None, "flat"
    url = f"https://query1.finance.yahoo.com/v8/finance/chart/{quote(q)}?interval=1d&range=1d"
    try:
        req = Request(url, headers={"User-Agent": "Mozilla/5.0 (Macintosh) MugiDashboard/1.0"})
        with urlopen(req, timeout=timeout, context=_SSL) as resp:
            data = json.loads(resp.read().decode("utf-8", errors="replace"))
    except Exception as e:
        return None, str(e), "flat"
    try:
        chart = data.get("chart") or {}
        err = chart.get("error")
        if err:
            return None, safe_clip(str((err or {}).get("description") or "unknown symbol"), 80), "flat"
        result = (chart.get("result") or [None])[0] or {}
        meta = result.get("meta") or {}
        price = meta.get("regularMarketPrice")
        prev = meta.get("chartPreviousClose") or meta.get("previousClose")
        sym_disp = str(meta.get("symbol") or q)
    except Exception:
        return None, "bad response", "flat"
    if price is None:
        return None, "no price", "flat"
    try:
        c = float(price)
    except (TypeError, ValueError):
        return None, "bad price", "flat"
    change = ""
    trend = "flat"
    try:
        p = float(prev)
        diff = c - p
        change = f"{diff:+.2f}"
        if diff > 0.0001:
            trend = "up"
        elif diff < -0.0001:
            trend = "down"
    except (TypeError, ValueError):
        pass
    line = f"{sym_disp} {c:.2f} ({change})" if change else f"{sym_disp} {c:.2f}"
    return line, None, trend


MARKET_SYMBOLS_MAX = 10


def feed_stocks() -> dict:
    try:
        from core.config import get_config

        raw = str((get_config().get("dashboard") or {}).get("stock_symbols") or "AAPL,MSFT,GOOG")
        symbols = [x.strip() for x in raw.replace(";", ",").split(",") if x.strip()][:MARKET_SYMBOLS_MAX]
    except Exception:
        symbols = ["AAPL", "MSFT"]
    rows: list[dict] = []
    # Serial Yahoo fetches must stay well under plugin route_request IPC timeout (avoid 502s).
    deadline = time.monotonic() + 24.0
    for sym in symbols:
        remain = deadline - time.monotonic()
        if remain < 0.5:
            break
        line, err, trend = _yahoo_quote(sym, timeout=min(5.0, max(1.5, remain)))
        if line:
            rows.append(
                {
                    "id": safe_clip(sym, 16),
                    "symbol": safe_clip(sym.upper(), 16),
                    "line": safe_clip(line, 120),
                    "trend": trend if trend in ("up", "down", "flat") else "flat",
                }
            )
        elif err:
            rows.append(
                {
                    "id": safe_clip(sym, 16),
                    "symbol": safe_clip(sym.upper(), 16),
                    "line": safe_clip(err, 80),
                    "trend": "flat",
                }
            )
    st = "ok" if rows else "empty"
    return build_payload(
        kind="markets",
        state=st,
        title="Markets",
        cache_ttl_seconds=300,
        # config_symbols = the user's configured list (even rows that failed to
        # quote), so the tile's edit menu reflects truth, not fetch luck.
        data={"rows": rows, "config_symbols": [s.upper() for s in symbols]},
    )


def feed_agent_blurb() -> dict:
    lines: list[str] = []
    try:
        pq = os.path.join(os.path.expanduser("~/.mugi"), "proactive_queue.jsonl")
        if os.path.isfile(pq):
            with open(pq, "r", encoding="utf-8", errors="replace") as f:
                tail = f.readlines()[-3:]
            for ln in tail:
                try:
                    j = json.loads(ln)
                    t = str(j.get("text") or j.get("message") or j.get("body") or "").strip()
                    if t:
                        lines.append(t)
                except Exception:
                    pass
    except Exception:
        pass
    try:
        insight = os.path.join(os.path.expanduser("~/.mugi/workspace"), "insights.md")
        if not lines and os.path.isfile(insight):
            with open(insight, "r", encoding="utf-8", errors="replace") as f:
                chunk = f.read(2000)
            for para in chunk.split("\n\n"):
                p = para.strip()
                if p and not p.startswith("#"):
                    lines.append(p[:400])
                    break
    except Exception:
        pass
    text = " · ".join(lines[:2]) if lines else "Ask Mugi anything from the composer below."
    return build_payload(
        kind="agent_blurb",
        state="ok",
        title="Mugi",
        cache_ttl_seconds=21600,
        data={"text": safe_clip(text, 600)},
    )


# Sensible defaults: high-volume general news from stable, fast RSS endpoints.
DEFAULT_NEWS_FEEDS = (
    "https://feeds.bbci.co.uk/news/world/rss.xml",
    "https://feeds.npr.org/1001/rss.xml",
)
NEWS_MAX_AGE_HOURS = 48
_NEWS_ITEM_CAP = 5
_ATOM_NS = "{http://www.w3.org/2005/Atom}"


def _configured_news_feeds() -> list[str]:
    """User-configurable feed URLs from `[dashboard].news_feeds` (list or comma/newline string)."""
    raw: object = None
    try:
        from core.config import get_config

        raw = (get_config().get("dashboard") or {}).get("news_feeds")
    except Exception:
        raw = None
    urls: list[str] = []
    if isinstance(raw, list):
        urls = [str(x).strip() for x in raw]
    elif isinstance(raw, str):
        urls = [x.strip() for x in raw.replace("\n", ",").split(",")]
    urls = [u for u in urls if safe_http_url(u)]
    return urls[:8] if urls else list(DEFAULT_NEWS_FEEDS)


def _rss_text(node, tag: str) -> str:
    el = node.find(tag)
    if el is None:
        el = node.find(_ATOM_NS + tag)
    return (el.text or "").strip() if el is not None and el.text else ""


def _parse_rss_datetime(s: str) -> datetime | None:
    """RFC 822 (RSS pubDate) or ISO 8601 (Atom updated/published)."""
    s = (s or "").strip()
    if not s:
        return None
    try:
        from email.utils import parsedate_to_datetime

        dt = parsedate_to_datetime(s)
        if dt is not None:
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            return dt.astimezone()
    except Exception:
        pass
    return _parse_dt_iso(s)


def _parse_feed_entries(xml_text: str) -> list[dict]:
    """Entries from one RSS 2.0 or Atom document: {title, url, published(datetime|None)}."""
    import xml.etree.ElementTree as ET

    try:
        root = ET.fromstring(xml_text)
    except ET.ParseError:
        return []
    entries: list[dict] = []
    # RSS 2.0: rss/channel/item ; Atom: feed/entry
    nodes = root.findall(".//channel/item") or root.findall(f"{_ATOM_NS}entry")
    for node in nodes:
        title = _rss_text(node, "title")
        if not title:
            continue
        link = _rss_text(node, "link")
        if not link:
            # Atom: link is an attribute (prefer rel="alternate" or first href).
            for lel in node.findall(_ATOM_NS + "link"):
                href = (lel.get("href") or "").strip()
                if href and lel.get("rel") in (None, "", "alternate"):
                    link = href
                    break
        pub_raw = (
            _rss_text(node, "pubDate")
            or _rss_text(node, "published")
            or _rss_text(node, "updated")
            or _rss_text(node, "{http://purl.org/dc/elements/1.1/}date")
        )
        entries.append({"title": title, "url": link, "published": _parse_rss_datetime(pub_raw)})
    return entries


def _fetch_feed(url: str, *, timeout: float) -> list[dict]:
    req = Request(url, headers={"User-Agent": "MugiDashboard/1.0 (+rss)"})
    with urlopen(req, timeout=timeout, context=_SSL) as resp:
        raw = resp.read(2_000_000).decode("utf-8", errors="replace")
    return _parse_feed_entries(raw)


def _normalize_news_title(title: str) -> str:
    return re.sub(r"[^a-z0-9 ]", "", title.lower()).strip()


def feed_news() -> dict:
    try:
        feed_urls = _configured_news_feeds()
        entries: list[dict] = []
        errors: list[str] = []
        # Serial fetches must stay under the plugin route_request IPC timeout.
        deadline = time.monotonic() + 18.0
        for url in feed_urls:
            remain = deadline - time.monotonic()
            if remain < 1.0:
                break
            try:
                entries.extend(_fetch_feed(url, timeout=min(8.0, remain)))
            except Exception as e:
                errors.append(f"{url}: {e}")
        if not entries:
            detail = safe_clip(errors[0], 160) if errors else "No items in the configured feeds."
            return build_payload(
                kind="news",
                state="empty",
                title="News",
                cache_ttl_seconds=1800,
                message=f"No news available. {detail}",
                data={"items": []},
            )

        # Newest first; undated entries sink to the bottom.
        entries.sort(
            key=lambda e: e["published"].timestamp() if e.get("published") is not None else float("-inf"),
            reverse=True,
        )
        now = datetime.now(timezone.utc).astimezone()
        fresh = [
            e
            for e in entries
            if e.get("published") is not None
            and (now - e["published"]).total_seconds() <= NEWS_MAX_AGE_HOURS * 3600
        ]
        # A stale feed shouldn't blank the tile — fall back to the newest items.
        pool = fresh if fresh else entries

        items: list[dict] = []
        seen_titles: set[str] = set()
        for e in pool:
            title = str(e.get("title") or "").strip()
            norm = _normalize_news_title(title)
            if not norm or norm in seen_titles:
                continue
            seen_titles.add(norm)
            su = safe_http_url(str(e.get("url") or "").strip())
            domain = ""
            if su:
                try:
                    from urllib.parse import urlparse as up

                    domain = up(su).netloc[:120] or ""
                except Exception:
                    domain = ""
            items.append(
                {
                    "id": safe_clip(su or title, 200),
                    "title": safe_clip(title, 220),
                    "url": su or "",
                    "domain": safe_clip(domain, 120),
                    "age_display": safe_clip(_age_short(e.get("published")), 16),
                }
            )
            if len(items) >= _NEWS_ITEM_CAP:
                break
        st = "ok" if items else "empty"
        return build_payload(
            kind="news",
            state=st,
            title="News",
            cache_ttl_seconds=1800,
            data={"items": items},
        )
    except Exception as e:
        return build_payload(
            kind="news",
            state="error",
            title="News",
            cache_ttl_seconds=1800,
            message=safe_clip(str(e), 200),
            data={"items": []},
        )


_KANBAN_ACTIVE_ITEM_CAP = 6
_KANBAN_RECENT_ITEM_CAP = 3


def _kanban_item(task: dict, *, recent: bool = False) -> dict:
    """Tile row for one task. Timestamps are kanban_storage ISO-UTC strings."""
    when = str(task.get("completed_at") or task.get("updated_at") or task.get("created_at") or "")
    return {
        "id": safe_clip(str(task.get("id") or ""), 64),
        "title": safe_clip(sanitize_plain_text(str(task.get("title") or "(untitled)"), max_len=300), 160),
        "status": safe_clip(str(task.get("status") or ""), 24),
        "assignee": safe_clip(str(task.get("assignee") or ""), 48),
        "age_display": safe_clip(_age_short(_parse_dt_iso(when)), 16),
        "recent": bool(recent),
    }


def feed_kanban() -> dict:
    """Agent work in progress: active board tasks plus just-finished ones.

    Read-only by design — ``list_tasks`` is a pure SELECT; the dashboard GET
    must never claim, reconcile, or otherwise mutate board state.
    """
    try:
        import kanban_storage as ks

        if not ks._kanban_enabled():
            return build_payload(
                kind="kanban",
                state="empty",
                title="Agent work",
                cache_ttl_seconds=300,
                message="The task board is turned off in Settings.",
                data={"items": []},
            )

        active = ks.list_tasks(status=sorted(ks.ACTIVE_STATUSES), limit=50)
        terminal = ks.list_tasks(status=sorted(ks.TERMINAL_STATUSES), order="desc", limit=30)
        # list_tasks orders terminal rows by (priority, created); the tile wants
        # the most recently *finished* ones.
        terminal.sort(key=lambda t: str(t.get("completed_at") or t.get("updated_at") or ""), reverse=True)

        running = [t for t in active if t.get("status") == ks.STATUS_IN_PROGRESS]
        queued = [t for t in active if t.get("status") in (ks.STATUS_READY, ks.STATUS_TODO)]
        blocked = [t for t in active if t.get("status") == ks.STATUS_BLOCKED]

        items = [_kanban_item(t) for t in (running + queued + blocked)[:_KANBAN_ACTIVE_ITEM_CAP]]
        items += [_kanban_item(t, recent=True) for t in terminal[:_KANBAN_RECENT_ITEM_CAP]]

        if not items:
            return build_payload(
                kind="kanban",
                state="empty",
                title="Agent work",
                cache_ttl_seconds=120,
                message="Nothing on the board — ask for multi-step work in chat and it lands here.",
                data={"items": [], "counts": {"running": 0, "queued": 0, "blocked": 0}},
            )

        bits = []
        if running:
            bits.append(f"{len(running)} running")
        if queued:
            bits.append(f"{len(queued)} queued")
        if blocked:
            bits.append(f"{len(blocked)} blocked")
        summary = " · ".join(bits) if bits else "All quiet — recent work below."

        return build_payload(
            kind="kanban",
            state="ok",
            title="Agent work",
            cache_ttl_seconds=30,
            data={
                "items": items,
                "summary": summary,
                "counts": {
                    "running": len(running),
                    "queued": len(queued),
                    "blocked": len(blocked),
                },
            },
        )
    except Exception as e:
        return build_payload(
            kind="kanban",
            state="error",
            title="Agent work",
            cache_ttl_seconds=120,
            message=safe_clip(str(e), 200),
            data={"items": []},
        )
