"""The owner's first-run interview, as an ordered set of atomic steps.

Why a state machine and not a playbook. The settings skill is a *reference
manual* — a settings map, an MCP verb table, file lists, propagation rules.
Handing that to a chat client and asking it to also *conduct* an interview
produced what you'd expect: several questions bundled into one message, and the
machinery narrated to an owner who never asked to see it ("I'll patch
Agent/Soul.md and propagate the sentinel…"). Conduct rules were added telling the
model not to do that. They are advice, and advice competes with the 128 lines of
machinery sitting in the same context.

So the server owns the script instead. It hands out **one** question, applies the
answer itself, and returns a single outcome line in the owner's language. Three
properties follow from the shape rather than from good behaviour:

* **One question per message** — the payload contains one question. There is no
  second question available to bundle.
* **The machinery cannot leak** — paths, tool names and sentinels never enter the
  payload, because the writes happen server-side. Discretion stops being
  something the model must remember.
* **Resume is exact** — progress is recorded in the vault's own config, so a
  dropped session continues at the next unanswered step rather than starting
  over or re-asking what's already settled.

This module is the *script*: pure functions over a config dict, no vault and no
I/O, so the order, the defaults and the resume logic are testable on their own.
The writes each step implies are named here and executed by the caller
(``alexandria_vault/mcp/tools.py``), which is where the writer and the render
engine already live.
"""

from __future__ import annotations

import json
import re
from dataclasses import dataclass, field
from typing import Callable, Optional

# Where the interview records itself. Kept inside `agent` (it is agent-setup
# state, not owner content) and deliberately *in the config* rather than in a
# side file: the brain should be able to answer "where did we get to?" from the
# same place it answers everything else about itself.
STATE_KEY = ("agent", "interview")

# What a technician-installed vault is called before anyone names it.
NEUTRAL_AGENT_NAME = "Alexandria"

# Areas the owner has named but not yet described — the per-area question queue.
PENDING_AREAS = "pending_areas"
AREA_DESC_PREFIX = "area_desc:"


@dataclass(frozen=True)
class Step:
    """One atomic question.

    ``ask`` is the whole message the owner should see: why the question is being
    asked, what a good answer looks like, and then the question — exactly one of
    them, and no agenda. The first cut was terser than this ("What are the main
    areas you want me across?") and read as an interrogation: these answers set
    how the brain behaves for good, and a handful of words gives the owner
    nothing to reason from, so they guess at what's wanted. Context is not
    preamble. An agenda still is.

    ``writes`` names what the executor must do; nothing in this module performs
    it.
    """

    id: str
    ask: str
    writes: tuple           # executor verbs — see _apply_step in tools.py
    kind: str = "text"      # text | list | confirm | show | done
    # A smart default, offered as a yes/no rather than an open question. Takes
    # the live config so it can build on earlier answers (short name from full
    # name); returns None when there is nothing sensible to offer.
    default: Optional[Callable[[dict], object]] = None
    # Phrasing for the confirm form. `{default}` is filled in by the caller.
    confirm: str = ""
    # Optional steps may be skipped without the interview reading as unfinished.
    optional: bool = False
    # What to say when this step is skipped. The generic "we can come back to it"
    # is wrong for a question that was simply answered no ("do you use the
    # desktop app?" — there is nothing to come back to).
    on_skip: str = "Skipped — we can come back to it."
    # Skipping normally means "don't do this". For a step whose subject the owner
    # has ALREADY committed to (they named the area; they're only declining to
    # describe it), skipping must still build the thing — declining to elaborate
    # is not declining to have it.
    apply_on_skip: bool = False
    # One short line, available only if the owner asks why a step exists. Never
    # volunteered — an interview that explains itself is a walkthrough.
    why: str = ""
    # Steps that are satisfied by config alone, regardless of whether this
    # interview asked. Lets a part-configured vault skip settled ground.
    satisfied: Optional[Callable[[dict], bool]] = field(default=None)


# ---- list answers -------------------------------------------------------------------
# The answer parameter is untyped — one step wants a string, the next a boolean,
# the next a list — and an untyped parameter is exactly where a client hands back
# the *rendered* form of a list instead of a list: a JSON string, or the owner's
# own comma-joined sentence. Both used to land whole. One "trait" reading
# "direct, warm, unhurried"; one Area called "Engineering, Fundraising, Family,
# Health"; and an address list that failed validation outright, mid-interview,
# with a message about JSON arrays. The owner then watched their own setup being
# repaired — which is the one thing this design exists to prevent.
#
# So coerce here, where the script already knows which steps are lists, rather
# than hoping every client types the shape correctly.

_BULLET = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+")
_SEPARATORS = re.compile(r"[,;\n]+")


def _clean(item: str) -> str:
    """One entry, stripped of the punctuation a rendered list arrives wearing."""
    return _BULLET.sub("", str(item or "")).strip().strip("\"'").strip()


def as_list(value) -> list:
    """Whatever a client sent for a list question → a list of strings.

    A real list of two or more entries is taken at its word — the client got it
    right, and splitting its entries further would break a name that legitimately
    contains a comma. Only the ambiguous shapes are unpacked: a bare string, or a
    one-entry list whose single entry is the whole answer.
    """
    if value is None or value is True or value is False:
        return []
    if isinstance(value, (list, tuple)):
        items = [_clean(v) for v in value]
        items = [i for i in items if i]
        if len(items) > 1:
            return items
        value = items[0] if items else ""
    text = str(value).strip()
    if not text:
        return []
    if text.startswith("[") and text.endswith("]"):
        try:
            parsed = json.loads(text)
        except ValueError:
            parsed = None
        if isinstance(parsed, list):
            return [i for i in (_clean(v) for v in parsed) if i]
    return [i for i in (_clean(p) for p in _SEPARATORS.split(text)) if i]


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 _first_name(full: str) -> str:
    return (str(full or "").strip().split() or [""])[0]


def _detect_tz() -> str:
    from scripts.config import detect_timezone
    return detect_timezone()


STEPS: tuple = (
    Step(
        id="agent_name",
        # Brains running Alexandria have an ADDRESS, not a name (Will,
        # 2026-08-28): the owner has the name; the brain has what its owner
        # calls it. The ask stays short and offers no default — the placeholder
        # "Alexandria" is the software's name, never volunteered as a fallback.
        # (The step id and write verb keep the legacy `agent_name` token for
        # resume-state and dispatch compatibility; the write lands in
        # `agent.address`, mirrored to `agent.name`.)
        ask="First: pick my address. Every brain running Alexandria has one — "
            "it's what you'll call me, and what I answer to and sign: your "
            "morning brief, anything I draft. What should my address be?",
        why="It's how you'll address this brain every day.",
        writes=("agent_name",),
        satisfied=lambda cfg: (_get(cfg, "agent", "address", default="")
                               or _get(cfg, "agent", "name", default=""))
        not in ("", NEUTRAL_AGENT_NAME),
    ),
    Step(
        id="owner_name",
        # "an area of your own" said *area* seven steps before the areas question
        # introduces the frame, so the one term the interview later defines
        # carefully arrived here as jargon. Plain word now; the areas step
        # connects it retroactively ("the standing parts of your life").
        ask="Now your side of it. I work on your behalf, so your name goes on "
            "what I produce — and you get a space of your own in here, for the "
            "things that are yours rather than any project's: your health, your "
            "family, your own admin. Give it to me the way you'd write it "
            "formally, since that's what I'll use when I'm writing for anyone "
            "but you. What's your full name?",
        why="It's how I'll address you, and it names your own space in here.",
        writes=("owner_name",),
        satisfied=lambda cfg: bool(_get(cfg, "owner", "name", default="")),
    ),
    Step(
        id="owner_short_name",
        ask="Day to day I'll use something shorter — in your brief, in "
            "reminders, and whenever I'm just talking to you rather than writing "
            "for anyone else. What should I call you?",
        kind="confirm",
        default=lambda cfg: _first_name(_get(cfg, "owner", "name", default="")),
        confirm="Day to day I'll use something shorter — in your brief, in "
                "reminders, and whenever I'm just talking to you rather than "
                "writing for anyone else. Should I just call you {default}?",
        writes=("config:owner.short_name",),
        satisfied=lambda cfg: bool(_get(cfg, "owner", "short_name", default="")),
    ),
    Step(
        id="owner_timezone",
        ask="Everything I do with a clock on it hangs off one timezone: when "
            "your brief lands, what counts as today, whether something is late. "
            "Give me wherever you actually work from, not where things are "
            "registered. Which timezone should I work in?",
        kind="confirm",
        default=lambda cfg: _detect_tz(),
        confirm="Everything I do with a clock on it hangs off one timezone: when "
                "your brief lands, what counts as today, whether something is "
                "late. Looks like you're in {default} — is that right?",
        writes=("config:owner.timezone_default",),
    ),
    Step(
        id="owner_emails",
        # This used to sell INBOUND mail ("forward mail straight into the brain"),
        # which needs the `email` feature and is off on a default brain — while
        # saying nothing about the one thing `owner.emails` unconditionally drives:
        # where the daily brief is delivered (scripts/features.py — "the digest has
        # nowhere to go without one"). An owner who decided forwarding wasn't for
        # them and skipped was silently switching off their own brief.
        ask="Two things run on this. Your morning brief has to land somewhere, "
            "and when mail comes in I have to know which addresses are you — "
            "otherwise I read your own replies as somebody else's. One address "
            "is enough to start; add any others you use, work and personal both, "
            "and I'll treat them all as you. Which email addresses should I know "
            "you by?",
        kind="list",
        why="It's where your brief goes, and how I recognise you in anything "
            "that comes through email.",
        writes=("config:owner.emails",),
        satisfied=lambda cfg: bool(_get(cfg, "owner", "emails", default=[])),
    ),
    Step(
        id="agent_character",
        ask="This next one is my character, and it's written into the brain "
            "rather than held for a session — every conversation we have starts "
            "by reading it, which is what stops you re-explaining yourself to a "
            "blank slate every morning. It's the difference between reading "
            "like a colleague and reading like a form letter. Three or four "
            "adjectives is "
            "plenty — direct, warm, dry, unhurried, sceptical, whatever fits. "
            "How should I come across?",
        kind="list",
        optional=True,
        writes=("agent_character",),
        satisfied=lambda cfg: bool(_get(cfg, "agent", "character", default=[])),
    ),
    Step(
        id="agent_voice",
        # "Anything you already know you want here?" was a yes/no-shaped question
        # on a free-text step, and it arrived third in a row of optional
        # preference questions — so the path of least resistance was "not
        # really", and this was the step most likely to be skipped for reasons
        # that had nothing to do with the owner having no preference. Naming the
        # dichotomies and asking them to pick one gives them something to answer
        # rather than something to volunteer.
        # Not "last one" — the closing offer already owns that phrase, and two
        # steps claiming to be the end reads as the interview losing count.
        ask="Still on how I sound, but mechanics rather than character: "
            "whether I lead with the headline or the reasoning, keep it short or "
            "thorough, say \"this will fail\" or \"this may present challenges\". "
            "Most people know one of these about themselves the moment it's "
            "named. Which of those do you want me to get right?",
        optional=True,
        why="One answer here changes every message I write you, and you can "
            "always adjust it later.",
        writes=("agent_voice",),
    ),
    Step(
        id="working_style",
        ask="Next, your working style: the standing rules I hold to without "
            "being reminded, like \"lead with the recommendation, not the "
            "options\" or \"never send anything out before I've seen it\". "
            "These aren't fixed — at the end of a day, when we go over what "
            "happened, I add what I've learned about how you like to work, so "
            "the list sharpens on its own. One is plenty to start with. What's "
            "a rule you'd want me to always follow?",
        optional=True,
        why="I keep sharpening these over time; one to start is enough.",
        writes=("working_style",),
    ),
    Step(
        id="areas",
        # The frame first, then one plain ask. The earlier version described all
        # four pillars in equal detail and buried the request in the last
        # sentence, so an owner reached the question having spent their attention
        # on a taxonomy they were not being asked about — and then had to work out
        # what shape of answer was wanted. Naming PEAK is what lets the other
        # three be dismissed in a clause: they are not the owner's job, and saying
        # so is what makes this a short question instead of a briefing.
        ask="The brain is built on one frame, PEAK: People, Events, Areas, "
            "Knowledge. Three of those fill themselves as we go — the people "
            "you deal with, the things that happen, what you come to know. "
            "Areas is the one I need from you, because it's the backbone the "
            "rest hangs off: the standing parts of your life, each with its own "
            "notes and its own to-do list. Work, a company or a team you're on, "
            "Personal, Family, Health, a side project, a hobby — that kind of "
            "thing. Just the names, one per line, four to six to start. What "
            "are your areas?",
        kind="list",
        why="Each one gets its own space, so things land somewhere sensible "
            "instead of in a pile.",
        # Names only — nothing is created yet. Each area is built once, with the
        # real description from the follow-up question, so no area is ever
        # scaffolded around a placeholder sentence that then has to be repaired.
        writes=("areas",),
    ),
    # The one question that is an OFFER rather than a fact about the owner, and
    # the only one whose work happens after the interview ends. It is asked, not
    # assumed, for two reasons that both survive scrutiny: a client with no
    # past-chat retrieval cannot honour a silent default anyway, and the corpus
    # in question is the owner's entire conversational history — the difference
    # between a feature and an incident is that somebody said yes. What it must
    # NOT do is explain the mechanism: the owner is being asked whether to start
    # from what they already said, not briefed on which component reads what.
    Step(
        id="history_backfill",
        ask="Last one, and it's an offer rather than a question about you. "
            "Most of what I should know, you've already said — to whatever "
            "assistant you were using before me, over months of conversations. "
            "I can't reach any of that on my own. The assistant you're talking "
            "to right now can: it can go back through those conversations, "
            "pull out what actually mattered, and hand it to me to file into "
            "the areas we just set up. It happens quietly in the background "
            "while we talk, and there's nothing for you to do. Shall I start "
            "from what you've already told it?",
        kind="confirm",
        optional=True,
        why="It's the difference between starting from an empty brain and "
            "starting from everything you've already worked through.",
        on_skip="Understood — we'll start fresh from here.",
        # Skipping is a real answer ("no"), and it has to be recorded as one:
        # without it the offer is merely unanswered, and an un-armed backfill is
        # indistinguishable from one nobody got round to asking about.
        apply_on_skip=True,
        writes=("backfill_arm",),
    ),
    # There is no "do you use the desktop app?" step, and its absence is the
    # design. It used to be the last question — asked, as an owner pointed out,
    # *from inside the desktop app*: a client that plainly knew the answer
    # querying the one person who couldn't see why it was being asked. The block
    # it gated is useful to anyone running any assistant, so it is simply handed
    # over at the end instead of being negotiated (see the `finish` write).
    Step(
        id="finish",
        ask="",
        kind="done",
        writes=("finish",),
    ),
)

BY_ID = {s.id: s for s in STEPS}
# Every step but the terminal one is a question the owner answers.
QUESTIONS = tuple(s for s in STEPS if s.kind != "done")

# ---- areas: one description question per area the owner named -----------------------
# The areas step collects names; each name then gets its own question. This is the
# one place the step list is not fixed in advance, and it is worth the machinery:
# an area's description is what the brain routes on, so "Acme — this area's
# work." is not a description, it is a placeholder wearing one. Asking per area
# keeps every turn atomic (one area, one question) instead of demanding a
# paragraph per area in a single wall of text.

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


def named_areas(cfg: dict) -> list:
    """Every area the owner listed — including ones already built.

    Deliberately not "the ones still to do": a question that disappears from the
    list the moment it is answered makes the total *shrink*, so the owner watches
    "10 of 12" become "10 of 11" and reasonably wonders what just got dropped.
    The list is fixed once they answer; only each entry's status changes."""
    return list(state(cfg).get(PENDING_AREAS) or [])


def pending_areas(cfg: dict) -> list:
    """Named areas that don't exist yet — the ones still to be described."""
    have = {_fold(a.get("name")) for a in (cfg.get("areas") or [])}
    return [n for n in named_areas(cfg) if _fold(n) not in have]


def _area_step(name: str) -> Step:
    return Step(
        id=f"{AREA_DESC_PREFIX}{name}",
        ask=f"Tell me about {name}. Say it the way you would out loud if someone "
            f"asked what you do there — who's involved, what you're driving at, "
            f"what would obviously belong under it. This is what I match new "
            f"things against when I'm deciding where they go, so a sentence or "
            f"two of real detail is worth more than a tidy label. What does "
            f"{name} cover?",
        kind="text",
        optional=True,
        why="It's what I route against, so the more it sounds like how you'd "
            "describe it out loud, the better things land.",
        on_skip=f"Set up {name} — you can flesh it out later.",
        apply_on_skip=True,
        writes=("area_description", name),
        # Answered once the area exists, however it came to.
        satisfied=lambda cfg, _n=name: any(
            _fold(a.get("name")) == _fold(_n) for a in (cfg.get("areas") or [])),
    )


def area_steps(cfg: dict) -> list:
    return [_area_step(n) for n in named_areas(cfg)]


def questions_for(cfg: dict) -> list:
    """The live question list: the fixed script, with one description question
    per named-but-not-yet-created area spliced in right after `areas`."""
    out = []
    for s in QUESTIONS:
        out.append(s)
        if s.id == "areas":
            out.extend(area_steps(cfg))
    return out


def step_for(step_id: str, cfg: dict) -> Optional[Step]:
    """Resolve a step id — fixed or per-area — against the live config."""
    if step_id in BY_ID:
        return BY_ID[step_id]
    if step_id.startswith(AREA_DESC_PREFIX):
        name = step_id[len(AREA_DESC_PREFIX):]
        if any(_fold(n) == _fold(name) for n in named_areas(cfg)):
            return _area_step(name)
    return None


def state(cfg: dict) -> dict:
    """This vault's interview record — ``{"completed": [...], "skipped": [...]}``."""
    raw = _get(cfg, *STATE_KEY, default={}) or {}
    if not isinstance(raw, dict):
        return {"completed": [], "skipped": []}
    out = {}
    for k in ("completed", "skipped", PENDING_AREAS):
        v = raw.get(k) or []
        out[k] = [str(x) for x in v] if isinstance(v, list) else []
    return out


def is_finished(cfg: dict) -> bool:
    """Is first-run setup over? ``agent.personalized`` is exactly that question —
    it is what the interview's own last step sets — so a vault carrying it has no
    first-run interview left to run, whether it was set up by this interview or
    by any of the ways that existed before it.

    Without this, a brain personalized before these tools shipped has no step
    record, so every step reads unanswered and the interview cheerfully offers to
    name an agent that has been called Margo for a week. Later changes go through
    the settings playbook one at a time; that is the documented split."""
    return bool((cfg.get("agent") or {}).get("personalized", True))


def is_answered(step: Step, cfg: dict) -> bool:
    """Has this step been settled — by this interview, or by config that was
    already there? The second half is what lets a part-configured vault (a
    restored brain, a half-finished session before this feature existed) skip
    ground that is demonstrably already covered."""
    if is_finished(cfg):
        return True
    st = state(cfg)
    if step.id in st["completed"] or step.id in st["skipped"]:
        return True
    return bool(step.satisfied and step.satisfied(cfg))


def plan(cfg: dict) -> list:
    """Every question with its status — the agenda, built only when asked for."""
    st = state(cfg)
    out = []
    for s in questions_for(cfg):
        if s.id in st["skipped"]:
            status = "skipped"
        elif is_answered(s, cfg):
            status = "done"
        else:
            status = "pending"
        out.append({"step": s.id, "status": status, "optional": s.optional})
    return out


def next_step(cfg: dict) -> Optional[Step]:
    """The one question to ask now, or None when the interview is answered out."""
    for s in questions_for(cfg):
        if not is_answered(s, cfg):
            return s
    return None


def progress(cfg: dict) -> dict:
    """Answered-so-far out of total. Present in every response, because "how much
    more of this is there" is the question an interview must never leave open."""
    live = questions_for(cfg)
    done = sum(1 for s in live if is_answered(s, cfg))
    nxt = next_step(cfg)
    # The total grows once the owner names their areas — it cannot be known
    # before then, and they have just said how many there are.
    return {"answered": done, "total": len(live),
            "remaining": len(live) - done,
            "step_number": (done + 1) if nxt else len(live)}


def default_for(step: Step, cfg: dict):
    """The smart default for a step, or None. Never raises — a default that
    can't be computed (no timezone readable, no name yet) simply isn't offered,
    and the step falls back to its open question."""
    if not step.default:
        return None
    try:
        value = step.default(cfg)
    except Exception:
        return None
    return value or None


def question(cfg: dict) -> dict:
    """The payload for one turn: one question, the progress, and a default to
    confirm when there is one.

    What is deliberately absent: file paths, tool names, sentinel strings, the
    remaining agenda, and any description of what the write will do. The client
    is given what it needs to ask a person a question — nothing it could recite
    as machinery.
    """
    step = next_step(cfg)
    if step is None:
        return {"done": True, "progress": progress(cfg),
                "say": "That's everything — we're set up."}
    default = default_for(step, cfg)
    ask = step.ask
    if default and step.kind == "confirm" and step.confirm:
        ask = step.confirm.replace("{default}", str(default))
    out = {"done": False, "step": step.id, "kind": step.kind, "ask": ask,
           "optional": step.optional, "progress": progress(cfg)}
    if default:
        out["default"] = default
    if step.why:
        out["why_if_asked"] = step.why
    return out
