"""Architect grounding (Phase 1 Workstream D) — ground the thesis in real LP feedback
WITHOUT leaking identity, by routing the Claude-facing synthesis through the redaction
boundary. The corpus is a defensibility oracle, not a generator.

Flow (redaction-rehydration.md):
  retrieve (local CRM)
   -> MINIMIZE   local Qwen condenses raw feedback into anonymous objection THEMES
                 ("first ask if Claude needs the record content at all")
   -> SCRUB      tokenize any residual identifiers (defense-in-depth, leak-proof core)
   -> REASON     Claude synthesizes the strongest honest rebuttals on the DE-IDENTIFIED
                 register (placeholders only)
   -> RE-HYDRATE map placeholders back locally
   -> HUMAN      a partner reviews before anything is used (guardrail #4).

The Architect only drafts; nothing is sent outbound. Every scrub/rehydrate is logged
(counts only) to interaction_log. minimize_fn / claude_fn are injectable so the boundary
enforcement is testable offline without Spark or Claude.
"""
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "redaction"))
from client import Boundary  # noqa: E402

_MINIMIZE_SYSTEM = (
    "You are a local privacy-preserving summarizer running on Ten31's own infrastructure. "
    "Given raw notes about investor conversations, extract ONLY the recurring objections, "
    "concerns, and sentiment as generic themes. STRIP every identifier: no names, firms, "
    "emails, amounts, dates, or places. Output a short de-identified bullet list of distinct "
    "objection themes with a rough frequency. Never include who said it.")


_NER_SYSTEM = (
    "You are a local NER extractor running on Ten31's own infrastructure. Return STRICT "
    "JSON only: {\"entities\":[{\"text\":\"...\",\"type\":\"PERSON|ORG|LOC\"}]}. Extract every "
    "person name, organization/firm/fund name, and specific location mentioned. Include "
    "partial names and nicknames. No commentary, JSON only.")


def _minimize_local(feedback_items, segment_key):
    """Condense raw LP feedback into anonymous objection themes on the LOCAL model
    (Spark Control /v1/chat/completions). RAISES if the local model is unreachable — the
    caller must then FAIL CLOSED, because without the local model neither this nor the
    NER backstop can run, and raw prose must never reach Claude unprotected."""
    raw = "\n".join(f"- {t}" for t in feedback_items if t)
    sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ingest"))
    import llm  # noqa: E402
    out = llm.chat(f"Segment: {segment_key or 'all'}\nRaw notes:\n{raw}\n\nDe-identified objection themes:",
                   system=_MINIMIZE_SYSTEM, max_tokens=500)
    if not (out or "").strip():
        raise RuntimeError("local minimize produced no output")
    return out


def _ner_local(text):
    """Local-Qwen NER backstop for UNKNOWN names not in the CRM dictionary. Returns
    [(surface, type)]. RAISES on a connection failure (caller fails closed); an empty
    result (model reachable, found nothing) is fine."""
    sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ingest"))
    import llm  # noqa: E402
    data = llm.chat_json(f"Text:\n{text}\n\nEntities JSON:", system=_NER_SYSTEM, max_tokens=700)
    if not data:
        return []
    return [(e.get("text"), e.get("type")) for e in (data.get("entities") or []) if e.get("text")]


def _synthesize_claude(register_text, segment_key):
    """Claude drafts the strongest honest rebuttals over the DE-IDENTIFIED register.
    Reuses the Architect's Claude client. Raises if not configured (handled by caller)."""
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    import architect_agent as aa  # noqa: E402
    client = aa._client()
    user = (
        # NB: the raw segment_key is intentionally NOT interpolated here — it is an
        # admin free-text field and must not become an un-scrubbed channel to Claude.
        "Below is a DE-IDENTIFIED register of recurring LP objections for one of our LP "
        "segments. Every identifier is a placeholder like [PERSON_1] or "
        "[ORG_1]; keep all placeholders intact and never invent new ones.\n\n"
        f"{register_text}\n\n"
        "For each distinct objection, draft Ten31's strongest HONEST rebuttal grounded in our "
        "thesis, and flag each as 'substantiated' or 'hand-wavy'. Also do a brief counter-evidence "
        "sweep (where might the objection be right?). Return readable markdown.")
    resp = client.messages.create(
        model=aa.MODEL, max_tokens=2000,
        system=[{"type": "text",
                 "text": "You are the Architect, pressure-testing Ten31's thesis against real LP "
                         "objections. " + aa.VOICE,
                 "cache_control": {"type": "ephemeral"}}],
        messages=[{"role": "user", "content": user}])
    return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")


def ground_objections(feedback_items, segment_key=None, db_path=None, actor="architect",
                      minimize_fn=None, claude_fn=None, ner_fn="default", conn=None):
    """Build a grounded objection register through the redaction boundary.

    FAILS CLOSED at every step: no Claude call happens unless the local minimize ran and
    the scrub (dict + regex + NER backstop) succeeded; a hallucinated token in Claude's
    output quarantines the draft. Always flagged needs_human_review (guardrail #4)."""
    minimize_fn = minimize_fn or _minimize_local
    claude_fn = claude_fn or _synthesize_claude
    if ner_fn == "default":
        ner_fn = _ner_local   # local-Qwen backstop for unknown names (None disables it, for tests)

    base = {"segment_key": segment_key, "needs_human_review": True}

    # 1) MINIMIZE locally. If the local model is unreachable -> FAIL CLOSED (no raw onward).
    try:
        themes = minimize_fn(feedback_items, segment_key)
    except Exception as exc:
        return {**base, "status": "local_model_unavailable", "reason": str(exc)}

    # 2) SCRUB. The Boundary fails closed if db_path is missing or the dictionary can't be
    #    built, and the NER backstop propagates a local-model failure here too.
    try:
        boundary = Boundary(db_path=db_path, actor=actor, ner_fn=ner_fn)
        # bucket=False: tokenize amounts/dates REVERSIBLY. The objection register needs no
        # magnitudes, so opaque placeholders both remove any inference signal AND avoid the
        # irreversible substance corruption a coarse bucket could introduce on a misparse.
        scrubbed = boundary.scrub([themes], task_id=f"ground:{segment_key or 'all'}", bucket=False, conn=conn)
    except Exception as exc:
        return {**base, "status": "scrub_unavailable", "reason": str(exc)}
    register = scrubbed["items"][0]
    handle = scrubbed["handle"]
    out = {**base, "scrubbed_register": register, "scrub_stats": scrubbed["stats"]}

    # 3) REASON on Claude over the de-identified register only.
    try:
        draft = claude_fn(register, segment_key)
    except Exception as exc:
        boundary.forget(handle)
        return {**out, "status": "claude_not_configured", "reason": str(exc)}

    # 4) RE-HYDRATE (strict). A residual placeholder = a Claude-hallucinated/smuggled token
    #    -> HARD STOP: quarantine the draft, never surface a de-anonymized version.
    rehy = boundary.rehydrate(draft, handle, strict=True, conn=conn)
    boundary.forget(handle)
    if rehy.get("error"):
        return {**out, "status": "rehydrate_failed", "draft_quarantined": True,
                "unknown_tokens": rehy.get("unknown_tokens")}
    return {**out, "status": "ok", "draft": rehy["text"]}
