#!/usr/bin/env python3
"""echo_reflect.py — H5: automatic session-reflection capture.  [v1.0 — wired]

Today memory only accrues when the agent decides to write. This makes accrual the
default: at session end the model extracts durable items from the conversation and
emits a PROPOSAL set; this module dedups them against the entity index, previews them,
and applies the accepted ones via the normal `capture` path — so nothing is written
without a go-ahead (honors the "show before large/sensitive writes" safety rule).

DIVISION OF LABOR:
  * Extraction is MODEL-side (only the LLM has the conversation). The agent produces a
    JSON array of proposals matching PROPOSAL_SCHEMA.
  * This script is the deterministic spine: validate -> classify (new/update/inbox vs the
    entity index) -> preview -> apply on confirm. No NL understanding lives here, so it's
    testable offline.

PROPOSAL_SCHEMA (one per durable item the agent wants to remember):
  {
    "title":   "Bob Smith",                 # required
    "kind":    "person",                    # required unless "inbox": true
    "body":    "Principal at Acme; ...",      # markdown body (optional)
    "aliases": ["bob", "rs"],               # optional
    "tags":    ["client"],                  # optional; the kind is always seeded as a tag
    "sources": ["_agent/sessions/..."],     # optional backward links
    "date":    "YYYY-MM-DD",                # optional (meeting/decision kinds)
    "domain":  "business",                  # optional (area kind)
    "inbox":   false,                        # route to inbox when the home is unknown
    "confidence": 0.0-1.0                    # agent's own confidence; gates auto-suggest
  }

CLI (echo.py reflect): dry-run previews; --apply writes — matching bootstrap/migrate/sweep.
"""
from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo                    # noqa: E402
import echo_index as idx_mod   # noqa: E402

MIN_CONFIDENCE = 0.6


def validate(proposals: list[dict]) -> tuple[list[dict], list[str]]:
    """Return (valid, errors). title always required; kind required unless inbox; a
    confidence below the floor is routed away (the agent should send it to the inbox)."""
    valid, errors = [], []
    for i, p in enumerate(proposals or []):
        if not str(p.get("title", "")).strip():
            errors.append(f"proposal[{i}]: missing title")
            continue
        is_inbox = bool(p.get("inbox"))
        if not is_inbox:
            kind = str(p.get("kind", "")).strip()
            if not kind:
                errors.append(f"proposal[{i}] '{p['title']}': missing kind (or set inbox:true)")
                continue
            if kind not in idx_mod.KIND_FOLDER:
                errors.append(f"proposal[{i}] '{p['title']}': unknown kind '{kind}'")
                continue
        if float(p.get("confidence", 1.0)) < MIN_CONFIDENCE:
            errors.append(f"proposal[{i}] '{p['title']}': confidence < {MIN_CONFIDENCE} — send to inbox instead")
            continue
        valid.append(p)
    return valid, errors


def classify(proposals: list[dict]) -> list[dict]:
    """Annotate each proposal with `_action` (create / update / inbox) and `_path`, by
    resolving its title against the entity index (alias-aware), so the preview shows
    create-vs-append and dedups against what's already in memory."""
    index = idx_mod.load()
    for p in proposals:
        if p.get("inbox") or not p.get("kind"):
            p["_action"], p["_path"] = "inbox", "inbox/captures/inbox.md"
            continue
        _, e = idx_mod.resolve(index, p["title"])
        if e and e.get("path"):
            p["_action"], p["_path"] = "update", e["path"]
        else:
            try:
                p["_path"] = idx_mod.derive_path(
                    p["kind"], idx_mod.slugify(p["title"]),
                    date=p.get("date"), domain=p.get("domain", "business"))
                p["_action"] = "create"
            except echo.EchoError as exc:
                p["_action"], p["_path"] = "error", str(exc)
    return proposals


def preview(proposals: list[dict]) -> str:
    """One confirmable line per proposal: action | kind | title -> target path."""
    rows = []
    for p in proposals:
        act = p.get("_action", "?")
        kind = "-" if act == "inbox" else p.get("kind", "?")
        rows.append(f"  {act:7} | {kind:9} | {p['title']}  ->  {p.get('_path', '?')}")
    return "\n".join(rows)


def apply(proposals: list[dict], confirm: bool = False) -> int:
    """Validate -> classify -> preview; with confirm=True, apply each via echo_ops.capture
    (which routes/indexes/links/logs, each self-lock-guarded). Without confirm it is a
    dry-run that writes nothing — the preview IS the confirmation step."""
    valid, errors = validate(proposals)
    for e in errors:
        print(f"skip: {e}", file=sys.stderr)
    if not valid:
        print("reflect: no valid proposals to apply.")
        return 0

    classify(valid)
    counts = {a: sum(1 for p in valid if p.get("_action") == a) for a in ("create", "update", "inbox", "error")}
    print(f"reflect: {len(valid)} proposal(s) — "
          f"{counts['create']} new, {counts['update']} update, {counts['inbox']} inbox"
          + (f", {counts['error']} error" if counts["error"] else ""))
    print(preview(valid))

    if not confirm:
        print("\nreflect: dry-run — re-run with --apply to write these to memory.")
        return 0

    import echo_ops  # lazy: the apply path pulls in the capture/index/link stack
    applied = gated = 0
    for p in valid:
        if p.get("_action") == "error":
            continue
        bodyfile = echo.temp_file((p.get("body") or "").encode()) if p.get("body") else None
        rc = echo_ops.capture(
            p.get("kind"), p["title"], bodyfile,
            aliases=p.get("aliases") or [], sources=p.get("sources") or [],
            tags=p.get("tags") or [],
            date=p.get("date"), domain=p.get("domain", "business"),
            inbox=bool(p.get("inbox")) or not p.get("kind"))
        applied += 1 if rc == 0 else 0
        gated += 1 if rc == 76 else 0
    print(f"reflect: applied {applied}/{len(valid)} proposal(s)."
          + (f" {gated} stopped at the duplicate gate — re-propose with the existing "
             f"entity's title (or capture --merge-into <slug>)." if gated else ""))
    return 0
