"""The first-run history backfill, as an ordered set of atomic directives.

Why this exists. A brain's first day is the one day it is empty, and it is also
the day the owner has *already* said most of what it needs to know — to some
other assistant, in a chat client, over months. ``docs/INGESTION.md`` §5 named
the gap ("the wrong shape for the first day of a brain's life, when the owner
already has the material") and v2.112.0 built the intake pipe. This module
points it at the one corpus the owner never has to produce: their own chat
history.

**The brain cannot read that history, and never will.** MCP is one-directional
— the client calls the server; there is no capability in the protocol, and no
API in any chat client, that lets a connector enumerate past conversations. The
server sees exactly what the model passes as an argument.

But the *model* can read it. A client model usually has some form of
past-conversation search, and it is already in a turn with this brain. So the
brain does not pull; it **directs**. This module is that script: a queue of
search directives derived from what the interview just collected, handed out one
at a time, with the model doing the retrieval and handing the text back.

Why a state machine and not one instruction. Exactly the lesson
:mod:`scripts.interview` records. Handing a model a goal ("go through your chat
history and file what matters") and hoping it decomposes well is how the
interview worked before it was driven, and it produced bundled work and leaked
machinery. The properties come from the shape:

* **One directive per turn** — there is no second directive available to batch.
* **Resume is exact** — progress is in the vault's own config, so an owner who
  closes the chat halfway resumes at the next unworked directive rather than
  re-searching what is already filed.
* **Targeted, not exhaustive** — the search terms come from the owner's own
  areas, so the brain only ever asks for conversations about things it already
  knows they care about. A whole-history dump would land on an empty corpus,
  route as ``low`` almost everywhere, and bury a fresh vault in a staging queue.

**These payloads are not owner-facing.** That is the one place this module
inverts its sibling: an interview payload is read to the owner verbatim, while a
backfill directive is an instruction *to the client*, and the owner should see
the results rather than the retrieval. Nothing here is written to be spoken.

Pure functions over a config dict — no vault and no I/O — so the order, the
resume logic and the directive text are testable on their own. The writes each
submission implies are executed by ``alexandria_vault/mcp/tools.py``, which is
where the router and the writer already live.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional

# Where the backfill records itself — beside the interview's record, in the
# config, for the same reason: the brain should answer "where did we get to?"
# from the place it answers everything else about itself.
STATE_KEY = ("agent", "backfill")

# One submission carries one directive's findings. A model that returns 40
# conversations for "Acme" has almost certainly stopped filtering for
# substance, and 40 model passes is a real bill on a brain that is minutes old.
# Overflow is REPORTED, never silently dropped.
MAX_CONVERSATIONS = 25

# Below this, a "conversation" is a title with no substance — a stub the model
# emitted to show its work. Filing it costs a model pass and produces a note
# saying nothing.
MIN_CONVERSATION_CHARS = 200


@dataclass(frozen=True)
class Directive:
    """One search directive: what to look for, and why it earns a turn.

    ``subject`` is what the model searches its own history for. ``terms`` are
    additional phrasings — a client whose search is literal finds nothing for
    "Acme" if the owner always wrote "the Acme raise".
    """

    id: str
    subject: str
    looking_for: str
    terms: tuple = ()


# The standing sweeps, run after the per-area ones. They map to the three PEAK
# pillars an area doesn't cover by itself (People, Events, Knowledge) and they
# run LAST on purpose: each area directive files notes, so by the time these
# broad ones arrive the router has a real corpus to match against instead of the
# bare scaffolding the interview left. Reversing the order was tried on paper and
# is worse — the broad sweeps return the most material at the lowest confidence,
# which is precisely the shape that fills a new vault's staging queue.
STANDING: tuple = (
    Directive(
        id="people",
        subject="the people this owner deals with regularly",
        looking_for="who they are, what the relationship is, what they're "
                    "working on together, and anything the owner would be "
                    "embarrassed to have forgotten",
        terms=("colleague", "my manager", "my co-founder", "my team",
               "introduced me to", "spoke with"),
    ),
    Directive(
        id="commitments",
        subject="decisions the owner made and commitments they took on",
        looking_for="what was decided, when, what they agreed to do or "
                    "deliver, deadlines, and anything still outstanding",
        terms=("I decided", "I agreed", "I need to", "by Friday", "deadline",
               "I promised"),
    ),
    Directive(
        id="preferences",
        subject="how this owner works and what they've said they want",
        looking_for="standing preferences, pet hates, how they like things "
                    "written or presented, tools they use, rules they hold to",
        terms=("I prefer", "I always", "I never", "don't ever", "I hate it when"),
    ),
)

# The instruction handed to the client model. Written to the *model*: it names
# the retrieval the brain cannot do, and it is explicit about verbatim text
# because the default behaviour of a model asked to fetch and file is to arrive
# with a tidy summary — which is lossy in exactly the way that matters, since the
# router and the entity resolver work off real text, not a précis of it.
INSTRUCTION = (
    "Search your own record of this owner's past conversations for {subject}. "
    "Use whatever past-chat or conversation-history search you have — this "
    "brain cannot see any of it, which is why it is asking you.\n\n"
    "Looking for: {looking_for}.\n\n"
    "Return each conversation you find as a SEPARATE entry carrying its title, "
    "its date, and its text VERBATIM. Do not summarize, merge or paraphrase — "
    "what gets filed is the original, and a summary files a summary forever. "
    "Skip anything with nothing durable in it: one-off drafting, debugging, "
    "trivia, and general questions that say nothing about this owner's life or "
    "work. Quality over count — returning nothing for a subject the owner never "
    "discussed is a correct answer, and better than padding.\n\n"
    "Then call backfill_submit with what you found. Don't narrate the search or "
    "list what you're about to do; the owner asked for this once and does not "
    "need it reported turn by turn."
)


def _get(cfg: dict, *path, default=None):
    node = cfg
    for p in path:
        if not isinstance(node, dict):
            return default
        node = node.get(p)
    return default if node is None else node


def _fold(name) -> str:
    return " ".join(str(name or "").split()).casefold()


# The owner's personal area is named after the owner — the interview creates it
# that way — so the generic "anything to do with {area}" would send a client off
# to search someone's entire history for their own name. That returns everything
# and therefore nothing. Caught driving the real flow, not by a unit test: the
# fixture vault's areas are all ordinary ones.
_PERSONAL_TERMS = ("my health", "my family", "my doctor", "my finances",
                   "my flat", "my house", "my parents", "personal admin")


def _is_personal_area(cfg: dict, name: str) -> bool:
    owner = str(_get(cfg, "owner", "name", default="") or "").strip()
    return bool(owner) and _fold(name) == _fold(owner)


def area_directives(cfg: dict) -> list:
    """One directive per area the owner set up, in the order they named them.

    The description is what the owner said the area covers, so it doubles as the
    ``looking_for`` clause — the model gets the owner's own words for what
    belongs under it, which is the same text the router matches against.
    """
    out = []
    for area in (cfg.get("areas") or []):
        name = str(area.get("name") or "").strip()
        if not name:
            continue
        desc = str(area.get("description") or "").strip().rstrip(".")
        personal = _is_personal_area(cfg, name)
        if personal:
            subject = ("this owner's own life outside their work — their health, "
                       "their family, their money, their personal admin")
            terms = _PERSONAL_TERMS
        else:
            subject = f"anything to do with {name}"
            terms = (name,)
        out.append(Directive(
            id=f"area:{name}",
            subject=subject,
            looking_for=(desc or f"whatever belongs under {name}") +
                        " — decisions, people involved, facts worth keeping, "
                        "and anything still open",
            terms=terms,
        ))
    return out


def directives(cfg: dict) -> list:
    """The live queue: every area, then the standing sweeps."""
    return area_directives(cfg) + list(STANDING)


def state(cfg: dict) -> dict:
    """This vault's backfill record.

    ``armed`` is set by the interview step; ``done``/``skipped`` are directive
    ids; the counters are cumulative across the whole run so a resumed backfill
    reports the total, not the last leg.
    """
    raw = _get(cfg, *STATE_KEY, default={}) or {}
    if not isinstance(raw, dict):
        raw = {}
    out = {
        "armed": bool(raw.get("armed")),
        "declined": bool(raw.get("declined")),
        "unsupported": bool(raw.get("unsupported")),
    }
    for k in ("done", "skipped"):
        v = raw.get(k) or []
        out[k] = [str(x) for x in v] if isinstance(v, list) else []
    for k in ("conversations", "filed", "staged"):
        v = raw.get(k)
        out[k] = v if isinstance(v, int) else 0
    return out


def is_armed(cfg: dict) -> bool:
    return state(cfg)["armed"]


def is_worked(directive: Directive, cfg: dict) -> bool:
    st = state(cfg)
    return directive.id in st["done"] or directive.id in st["skipped"]


def next_directive(cfg: dict) -> Optional[Directive]:
    """The one directive to work now, or None when there is no work to hand out.

    ``unsupported`` ends the queue here rather than at each caller, and that
    placement is the whole of the guarantee: a client without past-chat
    retrieval will not grow it mid-session, so every remaining directive is
    unworkable the moment the first one is. Checking it only in
    :func:`is_complete` left ``payload`` still cheerfully handing out directive
    two — which is precisely the nag the flag exists to prevent.
    """
    st = state(cfg)
    if not st["armed"] or st["unsupported"]:
        return None
    for d in directives(cfg):
        if not is_worked(d, cfg):
            return d
    return None


def is_complete(cfg: dict) -> bool:
    """Armed, with nothing further to hand out."""
    return is_armed(cfg) and next_directive(cfg) is None


def is_pending(cfg: dict) -> bool:
    """Armed, supported, and with work left — the condition a client should be
    pointed at ``backfill_next`` for."""
    return is_armed(cfg) and not is_complete(cfg)


def progress(cfg: dict) -> dict:
    live = directives(cfg)
    st = state(cfg)
    worked = sum(1 for d in live if is_worked(d, cfg))
    return {"worked": worked, "total": len(live),
            "remaining": len(live) - worked,
            "conversations_taken": st["conversations"],
            "filed": st["filed"], "staged": st["staged"]}


def payload(cfg: dict) -> dict:
    """One turn's directive — for the CLIENT, not to be read to the owner."""
    if not is_armed(cfg):
        return {"done": True, "armed": False,
                "note": "no history backfill is armed for this vault"}
    d = next_directive(cfg)
    if d is None:
        st = state(cfg)
        return {"done": True, "armed": True, "progress": progress(cfg),
                "note": ("this client reported no past-conversation search"
                         if st["unsupported"] else
                         "the backfill queue is worked out")}
    return {
        "done": False,
        "directive": d.id,
        "subject": d.subject,
        "search_terms": list(d.terms),
        "instruction": INSTRUCTION.format(subject=d.subject,
                                          looking_for=d.looking_for),
        "progress": progress(cfg),
        "submit_with": "backfill_submit",
        "if_nothing_found": "backfill_submit(directive=..., nothing_found=true)",
        "if_you_cannot_search_past_chats":
            "backfill_submit(directive=..., unsupported=true) — ends the "
            "backfill for this vault rather than asking again",
    }
