#!/usr/bin/env python3
"""Resolves a Row 2 verb id to its prompt.

A verb is a markdown file: frontmatter for the flags the dispatcher needs,
body for the prompt the agent receives. Local files in $FLEET_HOME/verbs
win over the shipped ones in config/verbs, per verb rather than wholesale,
so overriding one verb does not mean maintaining copies of the rest.

The rules that hold for every prompt verb do not live in the verb files.
They live in `_`-prefixed fragments alongside them, and this resolver
prepends them at `show` time -- see COMMON_FRAGMENTS below.
"""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import fleetlib  # noqa: E402

TRUE_WORDS = ("true", "yes", "1")

# A keystroke verb sends a key instead of a prompt. Allow-listed rather than
# passed through to AppleScript: the value ends up selecting what gets sent to
# a live terminal, and an unrecognised name must fail loudly here rather than
# become an unpredictable keystroke there.
KEY_NAMES = ("escape", "enter")

# A verb prompt runs in whatever repo the selected agent is working in, not
# in flightdeck's own repo -- so a verb file that names one of flightdeck's
# own sink scripts by a bare relative path (e.g. `bin/fleet-fail`) sends
# the agent hunting for a path that does not exist there. Verified live.
# `fleetlib.repo_root()` already knows exactly where flightdeck lives, so
# rather than baking that machine-specific absolute path into the shipped,
# git-tracked markdown (which install.sh's __REPO__/__PYTHON__ templating
# never does to a tracked source file -- only to generated output like the
# hooks snippet and the launchd plist), a verb file spells the token below
# and this resolver substitutes it at every `show`, using the interpreter's
# own idea of where it is running from. Correct wherever the checkout
# lives, and never goes stale the way a baked-in path would after a move.
REPO_TOKEN = "{{FLIGHTDECK_REPO}}"

# The shared spine. `_common.md` goes in front of every prompt verb; the rest
# are opted into by name in frontmatter (`common: git`), and the value must be
# a key of this map. Allow-listed for the same reason KEY_NAMES is: a
# `common: gti` that silently resolved to nothing would drop the staging rule
# its author believed they were getting, which is exactly the drift the
# fragments exist to remove. Unrecognised means the verb does not load.
PREAMBLE = "_common.md"
COMMON_FRAGMENTS = {"git": "_common-git.md"}


class FragmentError(Exception):
    """A fragment the composed prompt needs is missing, empty, or misnamed.

    Its own exception rather than a None return so the failure can be
    reported naming the FRAGMENT. Every prompt verb depends on `_common.md`,
    so a broken one takes out all of Row 2 at once, and "no such verb: diff"
    would send whoever is holding the deck hunting through diff.md for a
    problem that is not there.

    A `common:` value with no fragment behind it is the same failure reached
    from the other end -- the verb file is present and parses, and only the
    flag is wrong -- so it reports the same way rather than as a missing verb.
    """


def fragment_file(name):
    """The fragment file that wins, or None. Local beats shipped, as verbs do."""
    for base in (fleetlib.fleet_home() / "verbs",
                 fleetlib.repo_root() / "config" / "verbs"):
        candidate = base / name
        if candidate.is_file():
            return candidate
    return None


def load_fragment(name):
    """The fragment's text, or FragmentError. Never a silent empty string.

    Degrading to body-only when a fragment is missing would reintroduce the
    drift this mechanism removes, and do it invisibly -- the prompts would
    still be delivered, just quietly missing the rules that make them safe.
    """
    path = fragment_file(name)
    if path is None:
        raise FragmentError("missing fragment: {}".format(name))
    try:
        text = path.read_text(encoding="utf-8").strip()
    except Exception as err:
        raise FragmentError("unreadable fragment: {}: {}".format(name, err))
    if not text:
        raise FragmentError("empty fragment: {} ({})".format(name, path))
    return text.replace(REPO_TOKEN, str(fleetlib.repo_root()))


def verb_file(verb_id):
    """The file that wins for this id, or None. Local beats shipped.

    Verb ids ultimately choose a filename under a directory we control, so
    a `/`, a `\\`, or a `..` segment must never be allowed to walk the
    lookup outside `verbs/` -- pathlib silently discards `base` for an
    absolute id (e.g. one starting with `/`) and `..` climbs out of either
    verbs directory entirely. Ids are local, per-key config today, but
    reject the shape anyway rather than depend on that staying true.

    A leading `_` is rejected on the same principle: those files are shared
    fragments, not verbs. They carry no frontmatter deliberately, so
    parse_verb() would refuse them anyway -- but `fleet-verbs show _common`
    must fail because a fragment is not a thing a key can be bound to, not
    as a side effect of how the fragment happens to be written.
    """
    if not verb_id or verb_id in (".", "..") or "/" in verb_id or "\\" in verb_id:
        return None
    if verb_id.startswith("_"):
        return None
    for base in (fleetlib.fleet_home() / "verbs",
                 fleetlib.repo_root() / "config" / "verbs"):
        candidate = base / "{}.md".format(verb_id)
        if candidate.is_file():
            return candidate
    return None


def parse_verb(text):
    """Splits frontmatter from body. Returns None if the shape is wrong.

    Rejecting rather than guessing matters: a file without frontmatter is
    far more likely to be a half-written verb than a deliberate one, and
    sending a half-written prompt to an agent is worse than sending none.
    A body that is present but all whitespace is the same class of
    half-written file, so it is rejected the same way rather than being
    allowed through as a silently empty prompt.
    """
    if not text.startswith("---"):
        return None
    parts = text.split("---", 2)
    if len(parts) < 3:
        return None
    prompt = parts[2].strip()
    if not prompt:
        return None
    prompt = prompt.replace(REPO_TOKEN, str(fleetlib.repo_root()))
    meta = {}
    for line in parts[1].strip().splitlines():
        if ":" not in line:
            continue
        key, _, value = line.partition(":")
        meta[key.strip()] = value.strip()
    key = meta.get("key", "").strip().lower()
    if key and key not in KEY_NAMES:
        return None
    common = meta.get("common", "").strip().lower()
    if common and common not in COMMON_FRAGMENTS:
        raise FragmentError("unknown common: value {!r} in verb frontmatter "
                            "(known: {})".format(
                                common, ", ".join(sorted(COMMON_FRAGMENTS))))
    return {
        "key": key,
        "common": common,
        "requires": meta.get("requires", "").strip().lower(),
        "id": meta.get("id", ""),
        "label": meta.get("label", ""),
        "interrupt": meta.get("interrupt", "").lower() in TRUE_WORDS,
        "confirm": meta.get("confirm", "").lower() in TRUE_WORDS,
        "steer": meta.get("steer", "").lower() in TRUE_WORDS,
        "prompt": prompt,
    }


def compose(parsed):
    """Puts the shared fragments in front of a prompt verb's body.

    Composition happens here, after parsing and before any command reads
    `prompt`, so everything that hands a prompt to an agent -- `show`, and
    the resolved copy `resolved-path` materialises for the wake path -- gets
    the same composed text, and nothing has to remember to ask for it.

    Only prompt verbs compose. A keystroke verb (`key:`) sends a key and its
    body is never delivered to anyone. A steer verb's body is a deny message
    read inside a refused tool call, where a preamble addressed to an agent
    doing work would read as nonsense. Those two conditions are already how
    the dispatcher tells the three kinds apart.

    Preamble first, verb body last: the panel context frames everything that
    follows, and the specific ask is the last thing the agent reads.
    """
    if parsed["key"] or parsed["steer"]:
        return parsed
    pieces = [load_fragment(PREAMBLE)]
    if parsed["common"]:
        pieces.append(load_fragment(COMMON_FRAGMENTS[parsed["common"]]))
    pieces.append(parsed["prompt"])
    parsed["prompt"] = "\n\n".join(pieces)
    return parsed


def load_verb(verb_id):
    path = verb_file(verb_id)
    if path is None:
        return None, None
    try:
        parsed = parse_verb(path.read_text(encoding="utf-8"))
    except FragmentError:
        # Not an unreadable file: the verb parsed and named a fragment that
        # does not exist. Let it past the catch-all below so main() can say
        # which flag was wrong instead of denying the verb exists.
        raise
    except Exception:
        return None, None
    if parsed is None:
        return None, None
    # Deliberately outside the try above: that one turns an unreadable verb
    # file into "no such verb", which is the right answer for a verb and the
    # wrong one for a fragment. A FragmentError propagates to main(), which
    # reports it naming the fragment.
    return compose(parsed), path


def main(argv):
    # One place to turn a broken fragment into a named failure, rather than a
    # traceback, whichever subcommand tripped over it.
    try:
        return run(argv)
    except FragmentError as err:
        sys.stderr.write("fleet-verbs: {}\n".format(err))
        return 1


def run(argv):
    # --steer <id>: print a steer verb's body for use as a deny message.
    # Refuses any verb not marked `steer: true`. A Row 2 verb body dropped
    # into a deny message reads as the reason a call was refused, which is a
    # different register -- binding PUSH to a steer key would deny a call
    # with "Commit and push" as its justification.
    if len(argv) >= 3 and argv[1] == "--steer":
        resolved, _ = load_verb(argv[2])
        if resolved is None or not resolved.get("steer") or not resolved.get("prompt", "").strip():
            return 1
        sys.stdout.write(resolved["prompt"].strip() + "\n")
        return 0
    if len(argv) < 3:
        return 1
    command, verb_id = argv[1], argv[2]
    parsed, path = load_verb(verb_id)
    if parsed is None:
        sys.stderr.write("fleet-verbs: no such verb: {}\n".format(verb_id))
        return 1
    if command == "show":
        sys.stdout.write(parsed["prompt"] + "\n")
    elif command == "path":
        sys.stdout.write(str(path) + "\n")
    elif command == "resolved-path":
        # `path` above reports the SOURCE file -- config/verbs/<id>.md or
        # its ~/.fleet/verbs override -- which is what "which file won"
        # callers and tests want, and it's fine for that: it is still
        # git-tracked markdown and still contains the literal REPO_TOKEN
        # unsubstituted. `show` handles substitution correctly because it
        # returns the prompt STRING, already run through parse_verb()'s
        # replace(). But fleet-send's wake path (the one delivery path
        # that reaches a real terminal) never sees that string -- it
        # points an idle agent at a file on disk with "Read <path> and
        # follow it.", and the agent opens whatever `path` names. If that
        # were the source file, the agent would read the literal
        # "{{FLIGHTDECK_REPO}}" token instead of an absolute path.
        #
        # So this materialises a resolved copy -- parsed["prompt"], token
        # already substituted, i.e. exactly what `show` would print -- to
        # $FLEET_HOME/verbs-resolved/<id>.md and reports THAT path instead.
        # Regenerated on every call via write_text_atomic (temp file +
        # os.replace, so a concurrent reader never sees a half-written
        # file), which also means it always reflects the verb file's
        # content at the moment fleet-send resolves it -- consistent with
        # "a verb's prompt is fixed at press time": the copy is pinned to
        # what was true at that press, not to whatever the source says by
        # the time an idle agent gets around to reading it.
        #
        # Keyed by verb id alone, shared across sessions, deliberately: two
        # sessions staging the same verb concurrently are reading the same
        # source file, so they produce identical content, and the atomic
        # write means neither can observe the other's write in progress.
        # verb_id is already restricted to a safe filename shape by
        # verb_file() above (no "/", "\\", "..", ".").
        resolved = fleetlib.fleet_home() / "verbs-resolved" / "{}.md".format(verb_id)
        fleetlib.write_text_atomic(resolved, parsed["prompt"] + "\n")
        sys.stdout.write(str(resolved) + "\n")
    elif command == "keyinfo":
        sys.stdout.write("key={} requires={}\n".format(
            parsed["key"], parsed["requires"]))
    elif command == "flags":
        sys.stdout.write("interrupt={} confirm={}\n".format(
            str(parsed["interrupt"]).lower(), str(parsed["confirm"]).lower()))
    else:
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
