#!/usr/bin/env python3
"""Context operations dispatcher — pull, repair and push project.md (FLY-681).

Section-aware sync for project.md v3. Uses HTML comment markers to
delineate AI-generated content, workspace rules, and repo rules.

Usage:
    python context.py pull              # Fetch from cloud, write locally
    python context.py repair [--dry-run] # Re-seed markers from cloud
    python context.py push [--project-md] [--service-json] [--dry-run]

Note on push: FLY-710 (ADR-011) dropped the *section-merge* push — the
server owns project.md and delivers it in-band on config/generate
(FLY-708). FLY-1592 reinstates a deliberately narrower one for the case
that path cannot serve: a workspace whose repo the portal cannot crawl
(GitHub Enterprise, no App install) has no context at all, and nothing
else can put any there. The agent generates the two artifacts locally and
this pushes them to `PUT /api/relay/context` (FLY-603 / FLY-1467).

It is narrower in the way that matters: only this repo's own narrative is
ever sent. The rules are the workspace's, the relay appends them to every
read, and sending them back is what duplicates them — so the narrative is
cut out of the file on disk twice over (the AI section, then the relay's
appended `## Workspace Rules` / `## Repo Rules` / `## Status Workflow`
tail) and re-assembled into the marked-up shape the portal stores.
"""

import argparse
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from flydocs_api import get_client, output_json, fail
from context_parser import (
    parse_project_md,
    assemble_project_md,
    hash_sections,
    has_markers,
    build_workflow_text,
    inject_workflow_text,
    strip_workflow_text,
)

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

def _project_root(args: argparse.Namespace) -> Path:
    if args.root:
        return Path(args.root)
    from flydocs_api import find_project_root
    return find_project_root()


def _project_md_path(root: Path) -> Path:
    return root / "flydocs" / "context" / "project.md"


def _service_json_path(root: Path) -> Path:
    return root / "flydocs" / "context" / "service.json"


def _sync_state_path(root: Path) -> Path:
    return root / ".flydocs" / "sync-state.json"


def _read_sync_state(root: Path) -> dict:
    path = _sync_state_path(root)
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


def _write_sync_state(root: Path, state: dict) -> None:
    path = _sync_state_path(root)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")


def _fetch_cloud_context(client) -> tuple[str, int]:
    """Fetch project.md content and context version from cloud.

    Returns (projectMd content, contextVersion).
    """
    response = client.relay.get(
        "/config/generate",
        params={"format": "3", "includeContext": "true"},
    )
    context = response.get("context", {})
    project_md = context.get("projectMd")
    if not project_md:
        fail("Server returned no project.md content. Ensure context is configured in the dashboard.")
    context_version = response.get("contextVersion", 0)
    return project_md, context_version


# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------

def cmd_pull(args: argparse.Namespace) -> None:
    """Pull project context from cloud and write locally."""
    client = get_client()
    client.require_cloud("context pull")

    root = _project_root(args)
    project_md_content, context_version = _fetch_cloud_context(client)

    # Parse sections
    result = parse_project_md(project_md_content)
    sections = result["sections"]

    # FLY-684: Inject status workflow text into workspace-rules section
    try:
        statuses_response = client.relay.get("/auth/statuses")
        if statuses_response.get("mapping"):
            workflow_text = build_workflow_text(statuses_response)
            sections["workspace_rules"] = inject_workflow_text(
                sections["workspace_rules"], workflow_text
            )
            # Reassemble with injected workflow
            project_md_content = assemble_project_md(sections, ai_meta=result["ai_meta"])
    except Exception:
        # Non-fatal — statuses endpoint may not be available
        pass

    # Write project.md
    dest = _project_md_path(root)
    dest.parent.mkdir(parents=True, exist_ok=True)
    dest.write_text(project_md_content, encoding="utf-8")

    # Hash for sync state (hash AFTER workflow injection so push diff is clean)
    hashes = hash_sections(sections)

    # Write sync state
    _write_sync_state(root, {
        "contextVersion": context_version,
        "pulledAt": datetime.now(timezone.utc).isoformat(),
        "sections": hashes,
    })

    output_json({
        "success": True,
        "path": "flydocs/context/project.md",
        "contextVersion": context_version,
        "warnings": result["warnings"],
    })


def cmd_repair(args: argparse.Namespace) -> None:
    """Re-seed section markers from cloud state."""
    client = get_client()
    client.require_cloud("context repair")

    root = _project_root(args)
    local_path = _project_md_path(root)

    # Fetch cloud state
    cloud_content, context_version = _fetch_cloud_context(client)
    cloud_result = parse_project_md(cloud_content)
    cloud_sections = cloud_result["sections"]

    # Read local file
    if local_path.exists():
        local_content = local_path.read_text("utf-8")

        if has_markers(local_content):
            # Already has markers — parse and keep local content, just validate
            local_result = parse_project_md(local_content)
            merged_sections = local_result["sections"]
            warnings = local_result["warnings"]
        else:
            # No markers — wrap entire content as AI, merge rules from cloud
            merged_sections = {
                "ai": local_content.strip(),
                "workspace_rules": cloud_sections["workspace_rules"],
                "repo_rules": cloud_sections["repo_rules"],
            }
            warnings = ["Local file had no markers — wrapped as AI content, merged rules from cloud"]
    else:
        # No local file — use cloud content entirely
        merged_sections = cloud_sections
        warnings = ["No local project.md found — created from cloud state"]

    # Assemble with markers
    assembled = assemble_project_md(merged_sections, ai_meta=cloud_result["ai_meta"])

    if args.dry_run:
        output_json({
            "success": True,
            "dryRun": True,
            "warnings": warnings,
            "preview": assembled[:500] + ("..." if len(assembled) > 500 else ""),
        })
        return

    # Write
    local_path.parent.mkdir(parents=True, exist_ok=True)
    local_path.write_text(assembled, encoding="utf-8")

    # Update sync state
    hashes = hash_sections(merged_sections)
    _write_sync_state(root, {
        "contextVersion": context_version,
        "pulledAt": datetime.now(timezone.utc).isoformat(),
        "sections": hashes,
    })

    output_json({
        "success": True,
        "repaired": True,
        "path": "flydocs/context/project.md",
        "warnings": warnings,
    })


# ---------------------------------------------------------------------------
# Push helpers (FLY-1592) — pure, so the contract is testable without a relay
# ---------------------------------------------------------------------------

# The descriptor keys the relay's consumers assume are present. `structure` is
# NOT here: it is the intra-repo orientation section, useful but not required
# for a cross-repo answer (reference/service-descriptor-schema.md).
REQUIRED_DESCRIPTOR_KEYS = ("version", "name", "repoSlug", "purpose", "stack")

PUSH_SOURCE = "cli-push"


def extract_ai_section(content: str) -> tuple[str, list[str]]:
    """The part of a project.md that belongs to the AI section.

    A file carrying markers yields only what sits between
    `<!-- flydocs:ai-start … -->` and `<!-- flydocs:ai-end -->`; the
    workspace-rules and repo-rules sections stay local, because the server
    holds its own copies and re-emits them on every pull — push them back
    and the next pull has each rule twice.

    A file with no markers at all is sent whole, which is what
    `parse_project_md` already reports (with a warning that says so). That is
    the common case on disk, not the exception — see `local_narrative`.
    """
    result = parse_project_md(content)
    return result["sections"]["ai"], result["warnings"]


# The headings the relay appends to the served `project.md`, in the order
# `buildRepoContext` adds them (app:
# `src/app/api/relay/config/generate/route.ts`).
#
# What that route does, exactly, because the details decide the cuts below:
# `stripSectionMarkers` removes the `<!-- flydocs:* -->` marker LINES from the
# stored document and keeps everything between them, then it appends the
# workspace-rules and repo-rules COLUMNS under these headings. So a repo whose
# stored document has non-empty rules sections — the shape the portal panel
# edits — is served as
#
#     narrative
#     <stored workspace rules>      <- unlabelled, the markers are gone
#     <stored repo rules>           <- unlabelled
#     ## Workspace Rules
#     <the column>
#     ## Repo Rules
#     <the column>
#     ## Status Workflow
#     …
#
# and `context.py pull` writes that verbatim (re-wrapped in ai markers when
# `/auth/statuses` answers). Cutting at the first heading therefore leaves the
# unlabelled bodies inside the narrative, and pushing that back adds a copy on
# every cycle. Both halves have to go: the headed tail by heading, and the
# unlabelled bodies by matching them against what the server says it stores.
SERVER_APPENDED_HEADINGS = ("## Workspace Rules", "## Repo Rules", "## Status Workflow")

_H2_RE = re.compile(r"^##[ \t]+(?P<title>\S.*?)[ \t]*$", re.MULTILINE)

# The line `buildWorkflowText` always writes under its header. It is the only
# way to tell the appended workflow block from a narrative section that happens
# to be called `## Status Workflow` — the app emits nothing at all when the
# workspace has no provider mapping, so a lone header with no `Provider:` line
# under it is the repo's own writing.
_WORKFLOW_SIGNATURE = re.compile(r"^Provider:.*\|.*Last synced:", re.MULTILINE)


def strip_server_appended(content: str) -> tuple[str, list[str]]:
    """Content with the relay's appended, headed tail removed.

    The anchor is ordered, not positional-nearest. For each of the three
    appended headings, take its LAST occurrence; keep those that fall in the
    canonical order the route writes them in (workspace → repo → workflow),
    each after the one before it; cut at the earliest survivor.

    Why not "the earliest appended heading with only appended headings after
    it": rules are free markdown from the portal's Custom Rules editor, so a
    rules body may contain `## Coding Standards` — an ordinary thing to write.
    That heading sits between two appended ones and broke a backwards walk,
    leaving the whole workspace block in the narrative and growing the
    document by a column on every cycle.

    It still leaves a narrative's own `## Interfaces` alone when a section
    called `## Status Workflow` precedes it, because that workflow heading is
    then out of order with respect to the appended pair and is dropped. And it
    fails safe: a heading the rule cannot place leaves part of the tail
    uncut — it never advances the cut into narrative.
    """
    # The fenced workflow block first — `context.py pull` embeds that form in
    # the workspace-rules section, and it can survive into the AI section.
    cleaned = strip_workflow_text(content)

    last_seen: dict[str, int] = {}
    for m in _H2_RE.finditer(cleaned):
        title = f"## {m.group('title')}"
        if title in SERVER_APPENDED_HEADINGS:
            last_seen[title] = m.start()

    # A `## Status Workflow` with no `Provider: … | Last synced: …` under it is
    # the repo's own section, not the appended block.
    workflow_title = SERVER_APPENDED_HEADINGS[2]
    if workflow_title in last_seen:
        after = cleaned[last_seen[workflow_title]:]
        if not _WORKFLOW_SIGNATURE.search(after):
            del last_seen[workflow_title]

    anchors: list[tuple[str, int]] = []
    for title in SERVER_APPENDED_HEADINGS:
        position = last_seen.get(title)
        if position is None:
            continue
        if anchors and position <= anchors[-1][1]:
            continue  # out of order — not part of this tail
        anchors.append((title, position))

    if not anchors:
        return cleaned.strip(), []
    return cleaned[: anchors[0][1]].strip(), [title for title, _ in anchors]


def _normalize_ws(text: str) -> str:
    """Whitespace-insensitive form, for comparing two copies of one block."""
    return " ".join(text.split())


def strip_trailing_block(text: str, block: str) -> tuple[str, bool]:
    """`text` with `block` removed from its end, compared whitespace-loosely.

    The stored rules reach the served file through `stripSectionMarkers`,
    which collapses blank-line runs — so the copy on disk is the same prose
    with different whitespace, and an exact match would never fire.

    It removes ONE trailing occurrence, and only from the end: the assumption
    is that the local file is this repo's last pull, where the relay put
    exactly one unlabelled copy there. A second copy elsewhere in the file is
    not this function's to find.
    """
    if not block.strip():
        return text, False
    target = _normalize_ws(block)
    lines = text.split("\n")
    for index in range(len(lines) - 1, -1, -1):
        candidate = _normalize_ws("\n".join(lines[index:]))
        if candidate == target:
            return "\n".join(lines[:index]).rstrip(), True
        if len(candidate) > len(target):
            break
    return text, False


def local_narrative(
    content: str, stored_rules: dict | None = None
) -> tuple[str, list[str]]:
    """The narrative to push, from a `project.md` as it exists on disk.

    Three cuts, in the order the relay applied them in reverse: the AI section
    when markers are present, then the headed tail, then the unlabelled rules
    bodies the marker strip left behind — repo rules last in the document, so
    off the end first.

    `stored_rules` is what the server holds for this repo (`remote_rules`).
    Without it the third cut cannot be made, and a narrative that carries the
    bodies is pushed as-is — which is why the read happens before this call
    and why a failed read refuses by default.
    """
    ai_section, warnings = extract_ai_section(content)
    narrative, stripped = strip_server_appended(ai_section)
    if stripped:
        warnings.append(
            "Stripped server-appended section(s) before push: "
            + ", ".join(stripped)
            + " — the relay re-appends these from the workspace's own copies."
        )

    stored_rules = stored_rules or {}
    for key, label in (("repo_rules", "repo rules"), ("workspace_rules", "workspace rules")):
        block = stored_rules.get(key) or ""
        if not block.strip():
            continue
        narrative, found = strip_trailing_block(narrative, block)
        if found:
            warnings.append(
                f"Removed one trailing block matching the workspace's {label} "
                "from the narrative — the relay's marker strip leaves a copy "
                "there unlabelled."
            )
        else:
            warnings.append(
                f"The workspace stores {label} for this repo, but they are not "
                "at the end of the local narrative. If they appear inside it, "
                "they will be stored twice — check the pushed document."
            )

    return narrative, warnings


def assemble_push_document(
    narrative: str,
    rules: dict | None = None,
    version: int | None = None,
) -> str:
    """The `projectMd` to store, in the shape the portal stores.

    The portal persists `generatedProjectMd` as a marked-up document —
    `assembleProjectMd({ai, workspaceRules, repoRules})` in
    `src/lib/ai/generate-context-orchestrator.ts` — and its context-rules
    panel parses that document back into three editors. A marker-less push
    would land in the panel as one undifferentiated narrative and would be
    re-assembled with the rules editors empty on the next save there.

    The rules sections carry whatever the server already stores for this repo
    (read back before the push, the way the portal's own generation preserves
    them), not anything read from disk: the local copy is the flattened form
    the relay served, and sending it back is what doubles the rules.
    """
    rules = rules or {}
    ai_meta: dict[str, str] = {}
    if version is not None:
        ai_meta["version"] = str(version)
    ai_meta["generated"] = datetime.now(timezone.utc).date().isoformat()
    return assemble_project_md(
        {
            "ai": narrative,
            "workspace_rules": rules.get("workspace_rules", ""),
            "repo_rules": rules.get("repo_rules", ""),
        },
        ai_meta=ai_meta,
    )


class RemoteContext:
    """What the server holds for one repo — or why it could not be read.

    `ok` is the load-bearing field. The rules sections are needed twice: to
    subtract the relay's unlabelled copies from the local narrative, and to
    put the real ones back into the document being stored. Guessing at either
    means storing a narrative with the rules inside it, or replacing a
    populated rules section with an empty one — so a failed read refuses by
    default rather than proceeding on an assumption.
    """

    def __init__(self, ok: bool, rules: dict | None = None,
                 version: int | None = None, reason: str = ""):
        self.ok = ok
        self.rules = rules or {}
        self.version = version
        self.reason = reason


def remote_rules(client, repo_slug: str) -> RemoteContext:
    """Read the stored context for this repo from `GET /api/relay/context`.

    The route is newer than the push it serves (FLY-1467), so a relay without
    it fails here — with a reason, which the caller reports rather than
    swallows.
    """
    try:
        response = client.relay.get(
            "/context", params={"repo": repo_slug}, raise_on_error=True
        )
    except Exception as e:  # RelayError, or a transport failure raised as one
        code = getattr(e, "code", None)
        message = getattr(e, "message", None) or str(e)
        return RemoteContext(
            False, reason=f"{code}: {message}" if code else message
        )

    repos = (response or {}).get("repos") or []
    match = next((r for r in repos if r.get("repoName") == repo_slug), None)
    if match is None and len(repos) == 1:
        match = repos[0]
    if match is None:
        return RemoteContext(
            False,
            reason=(
                f'the response carries no entry for "{repo_slug}"'
                + (f" (it lists {len(repos)})" if repos else "")
            ),
        )

    parsed = parse_project_md(match.get("projectMd") or "")
    return RemoteContext(
        True,
        rules={
            "workspace_rules": parsed["sections"]["workspace_rules"],
            "repo_rules": parsed["sections"]["repo_rules"],
        },
        version=match.get("contextVersion"),
    )


def validate_descriptor(descriptor: object) -> list[str]:
    """Names of the required descriptor fields that are missing or empty.

    An empty string and an empty list count as missing: a descriptor with
    `"purpose": ""` answers no question a reader has.
    """
    if not isinstance(descriptor, dict):
        return list(REQUIRED_DESCRIPTOR_KEYS)
    missing: list[str] = []
    for key in REQUIRED_DESCRIPTOR_KEYS:
        value = descriptor.get(key)
        if value is None or value == "" or value == [] or value == {}:
            missing.append(key)
    return missing


def git_provenance(root: Path) -> dict:
    """Branch and commit for the tree the descriptor was derived from.

    Best-effort: a repo with no commits yet, or a tree that is not a git
    checkout, simply contributes no branch or commit. It never fails the
    push — provenance is a record of where the content came from, not a
    precondition for sending it.

    On a detached HEAD `git rev-parse --abbrev-ref HEAD` answers the literal
    string `HEAD`, which is not a branch name and would read as one; it is
    dropped rather than recorded. `dirty` says whether the tree had
    uncommitted changes when it was read — a descriptor derived from a dirty
    tree does not match the commit it names.
    """
    provenance: dict = {"generator": "agent"}

    def _git(*argv: str) -> str | None:
        try:
            return subprocess.check_output(
                ["git", *argv], cwd=str(root), stderr=subprocess.DEVNULL, timeout=5,
            ).decode().strip()
        except Exception:
            return None

    branch = _git("rev-parse", "--abbrev-ref", "HEAD")
    if branch and branch != "HEAD":
        provenance["branch"] = branch
    commit = _git("rev-parse", "HEAD")
    if commit:
        provenance["commit"] = commit
    status = _git("status", "--porcelain")
    if status is not None:
        provenance["dirty"] = bool(status)

    provenance["at"] = datetime.now(timezone.utc).isoformat()
    return provenance


def stamp_descriptor(descriptor: dict, provenance: dict) -> dict:
    """A copy of the descriptor carrying its provenance and generator.

    `structure` is kept. It is local-only in the sense that siblings do not
    read it, but the server's stored descriptor is what `flydocs update`
    writes back to `flydocs/context/service.json` — strip it here and the
    next update deletes this repo's own orientation section.
    """
    stamped = dict(descriptor)
    stamped["generatedBy"] = "agent"
    stamped["generatedAt"] = provenance.get("at")
    stamped["provenance"] = provenance
    return stamped


def build_push_payload(
    repo_slug: str,
    project_md: str | None = None,
    service_json: dict | None = None,
) -> dict:
    """The `PUT /api/relay/context` body (FLY-603 contract).

    Only the keys being sent appear: the route rejects a body carrying
    neither `projectMd` nor `serviceJson`, and an explicit `null` for the
    one not being pushed would read as "clear it".
    """
    payload: dict = {"repoSlug": repo_slug, "contextSource": PUSH_SOURCE}
    if project_md is not None:
        payload["projectMd"] = project_md
    if service_json is not None:
        payload["serviceJson"] = service_json
    return payload


def _resolve_repo_slug(client, descriptor: dict | None) -> tuple[str, str]:
    """The workspace's slug for this repo, and where it came from.

    Config before git: a URL-adopted repo (GitHub Enterprise, Bitbucket
    Server) has a slug the workspace assigned, and the git remote's
    host/path need not match it. The descriptor is the last source, not the
    first — it is a generated file and may have been copied from a sibling.
    """
    config = client.config or {}
    for value, source in (
        (config.get("repoSlug"), ".flydocs/config.json"),
        ((config.get("workspace") or {}).get("repoSlug"), ".flydocs/config.json"),
        (client.relay.repo_slug, "the git remote"),
        ((descriptor or {}).get("repoSlug"), "flydocs/context/service.json"),
    ):
        if value:
            return value, source
    fail(
        "repoSlug not found. Set it in .flydocs/config.json (or in "
        "flydocs/context/service.json) — it must match the slug the "
        "workspace registered for this repo."
    )
    return "", ""  # unreachable


# ---------------------------------------------------------------------------
# Push (FLY-1592)
# ---------------------------------------------------------------------------

def cmd_push(args: argparse.Namespace) -> None:
    """Push locally generated context to the workspace.

    With neither `--project-md` nor `--service-json`, both artifacts are
    pushed and a missing or unusable one is a warning and a skip. Naming one
    makes it a hard error instead — it was asked for by name.

    The read comes before the cut. What the server stores is needed to
    subtract the relay's unlabelled copies of the rules from the local
    narrative, so the order is: load the files, resolve the slug, read the
    stored context, then cut the narrative against it.
    """
    client = get_client()
    client.require_cloud("context push")

    root = _project_root(args)
    explicit = bool(args.project_md or args.service_json)
    want_project_md = args.project_md or not explicit
    want_service_json = args.service_json or not explicit

    warnings: list[str] = []
    local_text: str | None = None
    descriptor: dict | None = None

    if want_project_md:
        path = _project_md_path(root)
        if not path.exists():
            if args.project_md:
                fail(f"No project.md found at {path}. Generate it first (/generate-context).")
            warnings.append("No flydocs/context/project.md — skipped")
        else:
            local_text = path.read_text("utf-8")

    if want_service_json:
        path = _service_json_path(root)
        if not path.exists():
            if args.service_json:
                fail(f"No service descriptor found at {path}. Generate it first (/generate-context).")
            warnings.append("No flydocs/context/service.json — skipped")
        else:
            try:
                raw = json.loads(path.read_text("utf-8"))
            except json.JSONDecodeError as e:
                fail(f"flydocs/context/service.json is not valid JSON: {e}")
            missing = validate_descriptor(raw)
            if missing:
                fail(
                    "flydocs/context/service.json is missing required "
                    f"field(s): {', '.join(missing)}. Required: "
                    f"{', '.join(REQUIRED_DESCRIPTOR_KEYS)}."
                )
            descriptor = stamp_descriptor(raw, git_provenance(root))

    if local_text is None and descriptor is None:
        fail(
            "Nothing to push — neither flydocs/context/project.md nor "
            "flydocs/context/service.json has content to send. Run "
            "/generate-context first."
        )

    repo_slug, slug_source = _resolve_repo_slug(client, descriptor)

    # A descriptor copied from a sibling repo would otherwise store that
    # sibling's identity under this repo's name (FLY-1592 review).
    descriptor_slug = (descriptor or {}).get("repoSlug")
    if descriptor_slug and descriptor_slug != repo_slug:
        fail(
            "repoSlug mismatch: flydocs/context/service.json says "
            f"\"{descriptor_slug}\" but this repo is \"{repo_slug}\" "
            f"(from {slug_source}). Fix the descriptor, or the config, before "
            "pushing — a descriptor from another repo would be stored under "
            "this one."
        )

    # The stored context: needed for the narrative cut and for the rules
    # sections of the document being written. Skipped entirely when there is
    # no project.md to send, and in a plain dry run (which makes no request).
    remote = RemoteContext(True)
    read_performed = False
    if local_text is not None and (not args.dry_run or args.with_read):
        remote = remote_rules(client, repo_slug)
        read_performed = True
        if not remote.ok:
            if not args.no_preserve_rules:
                fail(
                    "Could not read the stored context for "
                    f"\"{repo_slug}\" — {remote.reason}.\n"
                    "Refusing: without it the rules this repo already has "
                    "would be replaced with empty sections in the stored "
                    "document, and the relay's own copies could not be "
                    "subtracted from the narrative. Re-run with "
                    "--no-preserve-rules to push anyway."
                )
            warnings.append(
                f"Could not read the stored context ({remote.reason}); "
                "--no-preserve-rules given, so the document is written with "
                "empty rules sections. The workspace's rules themselves are "
                "unaffected — the relay appends them on every read."
            )

    narrative: str | None = None
    if local_text is not None:
        text, cut_warnings = local_narrative(local_text, remote.rules)
        warnings.extend(cut_warnings)
        if not text:
            empty = (
                "flydocs/context/project.md carries no narrative of its own — "
                "everything in it is rules the relay appends on read. Write the "
                "project narrative above the '## Workspace Rules' heading "
                "(/generate-context does this)."
            )
            if args.project_md:
                fail(empty)
            warnings.append(empty + " Skipped.")
        else:
            narrative = text

    if narrative is None and descriptor is None:
        fail(
            "Nothing to push — flydocs/context/project.md has no narrative of "
            "its own and there is no descriptor to send."
        )

    next_version = (remote.version + 1) if isinstance(remote.version, int) else None
    project_md = (
        assemble_push_document(narrative, remote.rules, version=next_version)
        if narrative is not None else None
    )
    payload = build_push_payload(repo_slug, project_md, descriptor)
    sent = [key for key in ("projectMd", "serviceJson") if key in payload]

    if args.dry_run:
        note = (
            "Dry run: one read (GET /api/relay/context) and no write. The "
            "payload below is what the push would send."
            if read_performed else
            "Dry run: no request at all. The stored rules could not be read, "
            "so the preview may still carry rules the real push subtracts, "
            "and its rules sections are empty. Add --with-read for the real "
            "payload."
        )
        output_json({
            "success": True,
            "dryRun": True,
            "readPerformed": read_performed,
            "repoSlug": repo_slug,
            "sent": sent,
            "projectMdBytes": len(narrative.encode("utf-8")) if narrative else 0,
            "note": note,
            "warnings": warnings,
            "preview": (project_md or "")[:500]
            + ("..." if project_md and len(project_md) > 500 else ""),
        })
        return

    response = client.relay.put("/context", body=payload)

    output_json({
        "success": True,
        "repoSlug": repo_slug,
        "sent": sent,
        "contextVersion": response.get("contextVersion"),
        "warnings": warnings,
    })


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(
        prog="context.py",
        description="FlyDocs context operations — section-aware project.md sync",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    # pull
    p = sub.add_parser("pull", help="Pull project context from cloud")
    p.add_argument("--root", default=None, help="Project root override")

    # repair
    p = sub.add_parser("repair", help="Re-seed section markers from cloud state")
    p.add_argument("--root", default=None, help="Project root override")
    p.add_argument("--dry-run", action="store_true", help="Preview without writing")

    # push (FLY-1592)
    p = sub.add_parser("push", help="Push locally generated context to the cloud")
    p.add_argument("--root", default=None, help="Project root override")
    p.add_argument("--project-md", action="store_true",
                   help="Push project.md only (default: both artifacts)")
    p.add_argument("--service-json", action="store_true",
                   help="Push service.json only (default: both artifacts)")
    p.add_argument("--dry-run", action="store_true",
                   help="Report what would be sent; makes no request")
    p.add_argument("--with-read", action="store_true",
                   help="With --dry-run: perform only the read, so the "
                        "preview matches the real payload")
    p.add_argument("--no-preserve-rules", action="store_true",
                   help="Push even when the stored context cannot be read "
                        "(the document's rules sections are then empty)")

    args = parser.parse_args()
    commands = {
        "pull": cmd_pull,
        "repair": cmd_repair,
        "push": cmd_push,
    }
    commands[args.command](args)


if __name__ == "__main__":
    main()
