"""Canonical FlyDocs status vocabulary — one client-side source (FLY-1272).

Every client-side consumer of status names imports from here: the workflow
scripts (`issues.py`, `context_parser.py`, `workspace.py`, the local backend)
and the hooks (`stop-gate.py`, `prompt-submit.py`, `auto-approve.py`,
`post-transition-check.py`). The vocabulary was previously retyped in each of
those files, and it had already drifted in ways nothing could see:

- `issues.py` accepted TRIAGE as a transition *source* but not as a canonical
  status name, so the state existed for validation and did not exist for
  spelling.
- `post-transition-check.py` had never heard of TRIAGE at all, so an issue
  sitting in a provider triage inbox resolved to "unrecognised" and its
  transitions went unchecked.

That is the failure this module exists to prevent: a status added in one place
and silently misbehaving in the other four.

Authority
---------
The Relay Authority state machine (RLA-1 / FLY-1253) is the server-side source
of truth once it lands. This module is its **client mirror** — when the relay
begins serving the table, this file becomes the thing that loads and caches it,
and every consumer keeps importing the same names unchanged. Until then these
values are the client's own copy of a contract the relay already enforces.

Importing from a hook
---------------------
Hooks live in `.claude/hooks/` and get their own directory on `sys.path`, not
this one. They append this directory explicitly:

    SCRIPTS_DIR = SCRIPT_DIR.parent / "skills" / "flydocs-workflow" / "scripts"
    sys.path.append(str(SCRIPTS_DIR))
    from status_vocab import CANONICAL_STATUSES

`append`, not `insert(0)` — a hook must not let this directory shadow the
standard library, and it has no name to defend against. The path expression is
the same one `stop-gate.py` already uses to locate `issues.py`.
"""

# ---------------------------------------------------------------------------
# The vocabulary
# ---------------------------------------------------------------------------

# Canonical statuses in lifecycle-then-terminal order. This order is display
# order: `context_parser.build_workflow_text()` renders the provider mapping
# by walking it.
#
# Core only ever speaks these names. The relay translates to provider-native
# ones (Linear, Jira, ...) — see DEFAULT_STATUS_MAPPING for the fallback shape.
CANONICAL_STATUSES: tuple[str, ...] = (
    "BACKLOG",
    "READY",
    "IMPLEMENTING",
    "BLOCKED",
    "REVIEW",
    "TESTING",
    "COMPLETE",
    "ARCHIVED",
    "CANCELED",
    "DUPLICATE",
)

# States an issue can legitimately be *in* but must never be moved *to*.
# TRIAGE is a provider inbox (Linear's triage queue): work arrives there, and
# the workflow's answer is always to move it out. Keeping it out of
# CANONICAL_STATUSES is what stops `issues.py transition FLY-1 TRIAGE` from
# being a way to push work back into an inbox nobody owns.
SOURCE_ONLY_STATUSES: tuple[str, ...] = ("TRIAGE",)

# Every name a status may legitimately carry, whichever direction it travels.
ALL_STATUSES: tuple[str, ...] = CANONICAL_STATUSES + SOURCE_ONLY_STATUSES

# Terminal states carry no outbound transitions — nothing legally leaves them
# (FLY-1265, relay-lifecycle-authority-spec.md §4). This set used to also hold
# ARCHIVED and CANCELED, which is where "terminal" and "closed" were the same
# word for two different ideas: an archived or canceled issue is closed, but it
# revives to BACKLOG, and a client that calls that terminal refuses a legal
# revival the server would have accepted. The server derives its own terminal
# set from the same table (`state-machine.ts` TERMINAL_STATUSES) and gets
# exactly these two.
TERMINAL_STATUSES: frozenset[str] = frozenset({"COMPLETE", "DUPLICATE"})

# Closed states: work is off the board. Broader than TERMINAL_STATUSES because
# ARCHIVED and CANCELED are closed *and* revivable. Consumers that mean "stop
# nagging about this issue" or "store it under done/" want this set, not the
# terminal one.
CLOSED_STATUSES: frozenset[str] = frozenset(
    {"COMPLETE", "ARCHIVED", "CANCELED", "DUPLICATE"}
)

# Allowed targets per source state — the client mirror of the relay's canonical
# table (`state-machine.ts` TRANSITION_TABLE, spec §4). Every canonical status
# is a key: terminal states map to an empty set rather than being absent, so
# "no outbound edges" is stated rather than inferred from a missing key.
#
# Two client-only entries, both deliberate:
# - `TRIAGE` is a source-only alias the server canonicalizes to BACKLOG during
#   its current-state read, so it carries BACKLOG's targets plus BACKLOG itself
#   (server verdict SAME_STATE — legal, and a no-op the adapters already
#   handle). Nothing targets TRIAGE; work leaves an inbox, it never enters one.
# - Nothing else. Where this map and the server disagree the server wins, and
#   this map's only job is fast local feedback (see `transition_hint`).
VALID_TRANSITIONS: dict[str, frozenset[str]] = {
    "TRIAGE": frozenset(
        {"BACKLOG", "READY", "IMPLEMENTING", "CANCELED", "DUPLICATE", "ARCHIVED"}
    ),
    "BACKLOG": frozenset(
        {"READY", "IMPLEMENTING", "CANCELED", "DUPLICATE", "ARCHIVED"}
    ),
    "READY": frozenset(
        {"IMPLEMENTING", "BACKLOG", "CANCELED", "DUPLICATE", "ARCHIVED"}
    ),
    "IMPLEMENTING": frozenset({"REVIEW", "BLOCKED", "CANCELED", "ARCHIVED"}),
    "BLOCKED": frozenset({"IMPLEMENTING", "CANCELED", "ARCHIVED"}),
    "REVIEW": frozenset({"COMPLETE", "TESTING", "IMPLEMENTING", "CANCELED"}),
    "TESTING": frozenset({"COMPLETE", "IMPLEMENTING", "CANCELED"}),
    # Revival edges — an archived or canceled issue comes back to the backlog
    # and is re-refined from there.
    "ARCHIVED": frozenset({"BACKLOG"}),
    "CANCELED": frozenset({"BACKLOG"}),
    # Terminal: no reopen edge without a server-side policy override (§4 D7).
    "COMPLETE": frozenset(),
    "DUPLICATE": frozenset(),
}

# Common provider status names → canonical equivalents, for typo detection and
# error hints. Lookup is case-insensitive: keys are lowercase.
PROVIDER_SUGGESTIONS: dict[str, str] = {
    "in progress": "IMPLEMENTING",
    "in_progress": "IMPLEMENTING",
    "inprogress": "IMPLEMENTING",
    "started": "IMPLEMENTING",
    "active": "IMPLEMENTING",
    "open": "BACKLOG",
    # FLY-1265: "To Do" resolves to BACKLOG on the server heuristic
    # (`status-heuristic.ts` STATUS_HEURISTIC — BACKLOG lists it second, READY
    # last, and the position bonus decides it). The client said READY, so a
    # provider-native "To Do" got two different answers depending on which side
    # of the wire you asked.
    "to do": "BACKLOG",
    "todo": "BACKLOG",
    "to_do": "BACKLOG",
    "planned": "READY",
    "unstarted": "READY",
    "in review": "REVIEW",
    "in_review": "REVIEW",
    "code review": "REVIEW",
    "done": "COMPLETE",
    # Known divergence, left as-is deliberately: the server heuristic reads
    # "Closed" as ARCHIVED (it is an ARCHIVED hint). Only the server's answer
    # decides anything real — this table feeds typo hints — and flipping it
    # would start telling people their finished work belongs in ARCHIVED.
    # Recorded so the difference stays a decision rather than drift.
    "closed": "COMPLETE",
    "resolved": "COMPLETE",
    "merged": "COMPLETE",
    "completed": "COMPLETE",
    "cancelled": "CANCELED",
    "won't fix": "CANCELED",
    "wontfix": "CANCELED",
    "qa": "TESTING",
    "test": "TESTING",
    "verification": "TESTING",
    "accepted": "COMPLETE",
    "archived": "ARCHIVED",
    "duplicate": "DUPLICATE",
}

# Fallback only — a workspace's real mapping lives in `.flydocs/config.json`
# under `statusMapping`, written from the provider. This is what hooks fall
# back to when that file is unreadable.
DEFAULT_STATUS_MAPPING: dict[str, str] = {
    "BACKLOG": "Backlog",
    "READY": "Todo",
    "IMPLEMENTING": "In Progress",
    "BLOCKED": "Blocked",
    "REVIEW": "In Review",
    "TESTING": "QA",
    "COMPLETE": "Done",
    "ARCHIVED": "Archived",
    "CANCELED": "Canceled",
    "DUPLICATE": "Duplicate",
}

# ---------------------------------------------------------------------------
# Named subsets consumers need
# ---------------------------------------------------------------------------

# Statuses the Stop gate has a rule for (stop-gate.py). Anything else means the
# gate has nothing to say and should exit rather than fall through every branch.
GATED_STATUSES: frozenset[str] = frozenset(
    {"READY", "IMPLEMENTING", "REVIEW", "BLOCKED"}
)

# Statuses in which editing code needs no nudge (auto-approve.py). IMPLEMENTING
# is the intended one; the rest are states where a transition prompt would be
# noise or wrong — REVIEW/TESTING fixes are expected, and a closed issue is not
# a workflow error to shout about.
EDIT_OK_STATUSES: frozenset[str] = frozenset(
    {"IMPLEMENTING", "REVIEW", "TESTING", "COMPLETE", "CANCELED"}
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def is_status(name: str | None) -> bool:
    """True when `name` is a known status in any legitimate position."""
    return bool(name) and name.strip().upper() in ALL_STATUSES


def normalize(name: str | None) -> str | None:
    """Return the canonical spelling of `name`, or None when unrecognised.

    Returns None rather than a guess so callers can stay silent about a status
    they do not understand instead of acting on one they invented.
    """
    if not name or not name.strip():
        return None
    upper = name.strip().upper()
    return upper if upper in ALL_STATUSES else None


def suggest_canonical(name: str | None) -> str | None:
    """Map a provider-native status name to its canonical equivalent."""
    if not name:
        return None
    return PROVIDER_SUGGESTIONS.get(name.strip().lower())


def status_list() -> str:
    """Human-readable list of the statuses a transition may target."""
    return ", ".join(sorted(CANONICAL_STATUSES))


def is_unusual_transition(current: str | None, target: str | None) -> bool:
    """True when the map does not list `current -> target` (FLY-1265).

    The single predicate behind both warnings about an edge — `issues.py
    transition`'s pre-flight note and the post-transition hook's after-the-fact
    one. They fire at different moments and say different things, but "is this
    edge unusual?" must be one answer, or the pair contradicts itself.

    False — say nothing — in three cases, each of which is a state the client
    cannot honestly judge:

    - No current status, or one outside the vocabulary. Nothing to compare.
    - Same state in and out. The relay resolves this to `SAME_STATE` and
      accepts it (a re-run of a transition that already landed, or a provider
      status that canonicalizes onto the one it started from); warning would
      make the *successful* idempotent path the noisy one.
    - The edge is listed.
    """
    if not current or not target:
        return False
    source = current.strip().upper()
    dest = target.strip().upper()
    if source == dest:
        return False
    allowed = VALID_TRANSITIONS.get(source)
    if allowed is None:
        return False
    return dest not in allowed


def transition_hint(current: str | None, target: str) -> str | None:
    """Warning text for an edge this map does not list, or None when it does.

    FLY-1265 (spec §12.1): the client map is a *hint*, not a gate. The relay's
    lifecycle service owns rejection — it reads the real current state from the
    provider, applies workspace policy, and answers with `TRANSITION_ILLEGAL`
    and the allowed targets. This map only ever saw a status the client had
    cached locally, so when the two disagreed the client was the one more
    likely to be wrong, and it was the one that refused.

    So: a miss returns text to print, and the caller proceeds. `None` means
    either the edge is listed, or there is nothing to judge — an unknown or
    unmapped current state degrades to today's behavior rather than to a
    lockout (§4 unknown rule).
    """
    if not is_unusual_transition(current, target):
        return None
    source = (current or "").strip().upper()
    dest = target.strip().upper()
    allowed = VALID_TRANSITIONS.get(source, frozenset())

    if allowed:
        targets = ", ".join(sorted(allowed))
        detail = f"Usual targets from {source}: {targets}."
    else:
        detail = f"{source} is a terminal state — nothing usually leaves it."
    return (
        f"Unusual transition: {source} -> {dest}. {detail} "
        "Proceeding — the relay decides."
    )
