import argparse
import json
import re
import shutil
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path

from prd_install_skills import install_skills
from prd_reflections import validate_reflection


HUB_REPO_MARKERS = (
    "scripts/prd_install.py",
    "scripts/release_check.py",
    "scripts/local_workflow_check.py",
    "templates/repo-skeleton",
    ".codex-plugin/plugin.json",
)
SKELETON_DIR = "templates/repo-skeleton"
MCP_METADATA_SOURCE = "mcp/tool-metadata.json"
MCP_METADATA_TARGET = ".prd_plugin/mcp/tool-metadata.json"
STATE_DIR = ".prd_plugin/state"
SCRIPT_SCOPE_MANIFEST = "templates/script-install-scope.json"
SKILL_SCOPE_MANIFEST = "templates/skill-install-scope.json"
DOWNSTREAM_ALLOW_SCOPES = {"downstream_runtime", "downstream_optional"}
DEFAULT_SCRIPT_SCOPES = {"downstream_runtime"}

GITIGNORE_BEGIN = "# BEGIN PRD Plugin managed ignores"
GITIGNORE_END = "# END PRD Plugin managed ignores"
GITIGNORE_BLOCK_LINES = (
    GITIGNORE_BEGIN,
    "# Per-clone runtime state and request transport (never project truth).",
    ".prd_plugin/local/",
    ".prd_plugin/inbox/",
    ".prd_plugin/outbox/",
    ".prd_plugin/mailboxes/",
    "",
    "# Generated reports and views.",
    "request-report/",
    "prd-ui.html",
    "",
    "# Secrets and machine-local npm configuration.",
    ".env",
    ".env.local",
    ".env.*",
    "!.env.example",
    "!.env.sample",
    "!.env.template",
    ".npmrc",
    "npm-debug.log*",
    "",
    "# Python, temporary, log, and operating-system artifacts.",
    "__pycache__/",
    "*.py[cod]",
    "*.tmp",
    "*.log",
    ".DS_Store",
    "Thumbs.db",
    GITIGNORE_END,
)


def _hub_root():
    return Path(__file__).resolve().parents[1]


def _read_json(path):
    return json.loads(Path(path).read_text(encoding="utf-8-sig"))


def _is_hub_repo(path):
    root = Path(path).resolve()
    return all((root / marker).exists() for marker in HUB_REPO_MARKERS)


def _copy_skeleton_file(source, destination, force=False, dry_run=False):
    if destination.exists() and not force:
        return "skipped"
    if not dry_run:
        destination.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, destination)
    return "copied"


def _ensure_substrate_capability_catalog(hub_root, target_root, dry_run=False):
    """Install the plugin-owned intent catalog used by downstream audits."""
    source = Path(hub_root) / "templates" / "substrate-capabilities.json"
    destination = Path(target_root) / ".prd_plugin" / "templates" / "substrate-capabilities.json"
    if not source.is_file():
        return {"path": ".prd_plugin/templates/substrate-capabilities.json", "action": "source_unavailable"}
    changed = not destination.is_file() or destination.read_bytes() != source.read_bytes()
    if changed and not dry_run:
        destination.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, destination)
    return {"path": ".prd_plugin/templates/substrate-capabilities.json", "action": "would_update" if dry_run and changed else ("updated" if changed else "unchanged")}


def _ensure_tool_spec(hub_root, target_root, dry_run=False):
    """Install the authored tool-definition source (REQ-107).

    Lands at .prd_plugin/templates/tool-spec.json, next to the other installed
    templates, because both the MCP server and the UTCP manual builder read it
    as the SOURCE of tool definitions. Always refreshed: if it lagged, a
    downstream server would describe tools differently from the manual, which
    is the drift the spec exists to make impossible.
    """
    source = Path(hub_root) / "templates" / "tool-spec.json"
    rel = ".prd_plugin/templates/tool-spec.json"
    destination = Path(target_root) / ".prd_plugin" / "templates" / "tool-spec.json"
    if not source.is_file():
        return {"path": rel, "action": "source_unavailable"}
    changed = not destination.is_file() or destination.read_bytes() != source.read_bytes()
    if changed and not dry_run:
        destination.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, destination)
    return {"path": rel,
            "action": "would_update" if dry_run and changed else ("updated" if changed else "unchanged")}


def _ensure_tool_surface_catalog(hub_root, target_root, dry_run=False):
    """Install the canonical UTCP+MCP tool-surface contract (REQ-103).

    Lands at .prd_plugin/tool-surface.json so the MCP server, the UTCP surface,
    and downstream audits validate against one machine-readable catalog; always
    refreshed so it cannot lag the hub's registrations."""
    source = Path(hub_root) / "templates" / "tool-surface.json"
    destination = Path(target_root) / ".prd_plugin" / "tool-surface.json"
    if not source.is_file():
        return {"path": ".prd_plugin/tool-surface.json", "action": "source_unavailable"}
    changed = not destination.is_file() or destination.read_bytes() != source.read_bytes()
    if changed and not dry_run:
        destination.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, destination)
    return {"path": ".prd_plugin/tool-surface.json", "action": "would_update" if dry_run and changed else ("updated" if changed else "unchanged")}


def _ensure_downstream_gitignore(path, dry_run=False):
    """Create or refresh PRD Plugin's bounded block in a repo .gitignore.

    The downstream file belongs to the project, so content outside the marked
    block is byte-preserved. This is installer behavior rather than a skeleton
    file because npm excludes files named ``.gitignore`` from packed packages.
    """
    path = Path(path)
    if path.is_symlink():
        return {"path": ".gitignore", "action": "skipped_symlink"}
    existed = path.exists()
    if existed and not path.is_file():
        return {"path": ".gitignore", "action": "skipped_not_a_file"}

    raw = b""
    if existed:
        try:
            raw = path.read_bytes()
        except OSError:
            return {"path": ".gitignore", "action": "skipped_unreadable"}

    has_bom = raw.startswith(b"\xef\xbb\xbf")
    try:
        text = raw.decode("utf-8-sig")
    except UnicodeDecodeError:
        return {"path": ".gitignore", "action": "skipped_non_utf8"}

    newline = "\r\n" if b"\r\n" in raw else "\n"
    block = newline.join(GITIGNORE_BLOCK_LINES)
    marker = lambda value: list(re.finditer(
        rf"(?m)^{re.escape(value)}\r?$", text
    ))
    starts = marker(GITIGNORE_BEGIN)
    ends = marker(GITIGNORE_END)

    if not starts and not ends:
        if not text:
            updated = block + newline
        else:
            separator = "" if text.endswith(newline * 2) else (
                newline if text.endswith(newline) else newline * 2
            )
            updated = text + separator + block + newline
    elif len(starts) == 1 and len(ends) == 1 and starts[0].start() < ends[0].start():
        end_position = ends[0].end()
        replacement = block
        # The line regex consumes CR but leaves LF behind for CRLF files.
        # Consume that LF and emit the detected newline so the refreshed block
        # cannot introduce a lone-LF line ending into a CRLF project file.
        if text[end_position:].startswith("\n"):
            end_position += 1
            replacement += newline
        updated = text[:starts[0].start()] + replacement + text[end_position:]
    else:
        return {
            "path": ".gitignore",
            "action": "skipped_malformed_markers",
            "begin_markers": len(starts),
            "end_markers": len(ends),
        }

    encoded = ((b"\xef\xbb\xbf" if has_bom else b"")
               + updated.encode("utf-8"))
    if encoded == raw:
        action = "already_configured"
    elif not existed:
        action = "would_create" if dry_run else "created"
    else:
        action = "would_update" if dry_run else "updated"

    if encoded != raw and not dry_run:
        path.parent.mkdir(parents=True, exist_ok=True)
        temporary = None
        try:
            with tempfile.NamedTemporaryFile(
                mode="wb",
                dir=path.parent,
                prefix=path.name + ".prd-plugin.",
                suffix=".tmp",
                delete=False,
            ) as handle:
                handle.write(encoded)
                temporary = Path(handle.name)
            temporary.replace(path)
        finally:
            if temporary is not None and temporary.exists():
                temporary.unlink()

    return {
        "path": ".gitignore",
        "action": action,
        "managed_patterns": sum(
            1 for line in GITIGNORE_BLOCK_LINES
            if line and not line.startswith("#") and not line.startswith("!")
        ),
    }


def _find_state_overwrites(hub_root, target_root):
    """Return existing protected project files a destructive reset would overwrite."""
    overwrites = []
    source_root = Path(hub_root) / SKELETON_DIR
    if not source_root.is_dir():
        return overwrites
    for source in sorted(source_root.rglob("*")):
        if not source.is_file():
            continue
        rel = str(source.relative_to(source_root)).replace("\\", "/")
        if not _is_state_protected(rel):
            continue
        dest = Path(target_root) / rel
        if dest.exists():
            overwrites.append(rel)
    return overwrites


def _prompt_for_state_overwrite(overwrites):
    """Require an unmistakable confirmation before erasing project truth.

    Returns True if confirmed, False otherwise.
    """
    print("DESTRUCTIVE RESET REQUESTED: --force --yes will erase protected PRD Plugin project data.")
    print("The following existing state/configuration files would be replaced with skeleton defaults:")
    for rel_path in overwrites:
        print(f"  - {rel_path}")
    print("This may erase IDs, tracking, requests, decisions, health findings, configuration, and session memory.")
    print("For a normal safe update, remove --yes and run: npx prd-install . --force")
    if not sys.stdin.isatty():
        print("Refusing destructive reset because this is a non-interactive session.")
        return False
    try:
        answer = input(
            "To erase these files, type RESET PRD STATE exactly; anything else cancels: "
        ).strip()
    except (EOFError, KeyboardInterrupt):
        return False
    return answer == "RESET PRD STATE"


class StateOverwriteError(RuntimeError):
    """Raised when the installer refuses to overwrite populated state files."""

    pass


# Files the installer must never overwrite in a downstream repo, even with
# --force. The repo's own README belongs to the project, not the plugin —
# overwriting it made downstream agents think the repo *was* the plugin.
NEVER_OVERWRITE_DOWNSTREAM = {"README.md"}

# Codex reads ONE hooks file, so the repo's own hook entries live in the same
# file as the plugin's. A wholesale copy therefore deletes them (REQ-155).
# _merge_codex_hooks below refreshes the plugin's entries in place instead.
CODEX_HOOKS_REL = ".codex/hooks.json"
PRD_HOOK_MARKER = "prd_hook_dispatch"

# User-owned files: created if absent, but never overwritten by --force alone —
# only when --yes is also given. This is what lets `prd-install . --force` refresh
# the plugin's own files (hooks, skills, scripts, manifests, templates) while
# preserving the repo's data and settings (REQ-075).
STATE_PROTECTED_PREFIXES = (
    ".prd_plugin/state/",
    ".prd_plugin/ids/",
    ".prd_plugin/local/",
)
STATE_PROTECTED_FILES = {
    ".prd_plugin/config.json",
    ".prd_plugin/services.json",
}


def _is_state_protected(rel_str):
    return (rel_str in STATE_PROTECTED_FILES
            or rel_str.startswith(STATE_PROTECTED_PREFIXES))


def _copy_skeleton(hub_root, target_root, force=False, allow_state=False,
                   dry_run=False, skip_prefixes=None):
    source_root = Path(hub_root) / SKELETON_DIR
    skip_prefixes = tuple(skip_prefixes or ())
    results = []
    for source in sorted(source_root.rglob("*")):
        if not source.is_file():
            continue
        relative = source.relative_to(source_root)
        rel_str = str(relative).replace("\\", "/")
        # Plugin-primary Claude installs skip project .claude/skills (delivered
        # by the enabled plugin instead).
        if skip_prefixes and rel_str.startswith(skip_prefixes):
            continue
        destination = Path(target_root) / relative
        # Protection rules:
        #   README.md          — never overwritten (project-owned).
        #   state / config     — overwritten only with --yes (allow_state).
        #   everything else    — refreshed by --force (plugin-owned).
        protected = (rel_str in NEVER_OVERWRITE_DOWNSTREAM
                     or rel_str == CODEX_HOOKS_REL
                     or (_is_state_protected(rel_str) and not allow_state))
        eff_force = force and not protected
        action = _copy_skeleton_file(source, destination, force=eff_force, dry_run=dry_run)
        # "preserved" = force was requested but this protected file was kept.
        if force and protected and action == "skipped" and destination.exists():
            action = "preserved"
        results.append({"path": rel_str, "action": action})
    return results


def claude_skills_opted_in(target_root, options):
    """Whether this repo wants `.claude/skills/` written and refreshed.

    Claude is plugin-primary, so the explicit `--claude-skills` escape hatch
    opts a repo in. But a repo that already HAS `.claude/skills/` opted in on a
    previous install, and a plain `--force` refresh was skipping it — leaving
    that host running older method content than Codex and OpenCode after every
    update, silently (REQ-147). An existing directory is that earlier choice,
    so it counts.
    """
    if not options.get("claude"):
        return False
    if options.get("claude_skills"):
        return True
    return (Path(target_root) / ".claude" / "skills").is_dir()


def _skeleton_skip_prefixes(options):
    """Skeleton paths to skip. The skeleton never plants project .claude/skills —
    install_skills is the canonical writer for the --claude-skills escape hatch,
    and the default Claude delivery is the enabled plugin. This also keeps
    .claude/skills out of installs where Claude is off."""
    return (".claude/skills/",)


def _load_script_scopes(hub_root):
    manifest_path = Path(hub_root) / SCRIPT_SCOPE_MANIFEST
    if not manifest_path.exists():
        return {}
    data = _read_json(manifest_path)
    scripts = data.get("scripts", {})
    if not isinstance(scripts, dict):
        return {}
    return {
        name: metadata.get("install_scope")
        for name, metadata in scripts.items()
        if isinstance(metadata, dict)
    }


def _install_scripts(hub_root, target_root, allowed_scopes, force=False, dry_run=False):
    source_dir = Path(hub_root) / "scripts"
    target_dir = Path(target_root) / ".prd_plugin" / "scripts"
    scopes = _load_script_scopes(hub_root)
    installed = []
    skipped = []
    for script_name, scope in sorted(scopes.items()):
        if scope not in allowed_scopes:
            continue
        source = source_dir / script_name
        if not source.is_file():
            continue
        destination = target_dir / script_name
        if destination.exists() and not force:
            skipped.append(script_name)
            continue
        if not dry_run:
            target_dir.mkdir(parents=True, exist_ok=True)
            shutil.copy2(source, destination)
        installed.append(script_name)
    return {"installed": installed, "skipped": skipped}


def _detect_installed_version(hub_root):
    for manifest_path in (
        ".codex-plugin/plugin.json",
        ".opencode/plugin.json",
        ".claude-plugin/plugin.json",
    ):
        path = Path(hub_root) / manifest_path
        if path.is_file():
            data = _read_json(path)
            version = data.get("version")
            if version:
                return str(version)
    return None


def _git_remote_url(hub_root):
    import subprocess

    try:
        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            cwd=str(hub_root),
            capture_output=True,
            text=True,
            check=False,
        )
        if result.returncode == 0:
            return result.stdout.strip()
    except Exception:
        pass
    return None


def _to_https_git_url(url):
    if url.startswith("https://"):
        return url
    if url.startswith("git@"):
        parts = url[4:].replace(":", "/", 1)
        return f"https://{parts}"
    return url


def _detect_opencode_plugin_spec(hub_root, version, override=None):
    if override:
        return override

    package_json = Path(hub_root) / "package.json"
    if package_json.is_file():
        try:
            pkg = _read_json(package_json)
            if pkg.get("name") == "prd-plugin":
                return f"prd-plugin@^{version}"
        except Exception:
            pass

    remote = _git_remote_url(hub_root)
    if remote:
        base = _to_https_git_url(remote).rstrip("/")
        return f"prd-plugin@git+{base}#v{version}"
    return f"prd-plugin@file://{Path(hub_root).resolve()}"


STALE_ROOT_SCRIPTS = frozenset(
    {
        "archive_automation_session.py",
        "automation_guard.py",
        "gap_audit.py",
        "local_workflow_check.py",
        "message_check.py",
        "prd_doctor.py",
        "prd_install.py",
        "prd_install_skills.py",
        "prd_selective_promote.py",
        "prd_self_audit.py",
        "release_check.py",
        "request_export.py",
        "request_import.py",
        "request_mailbox.py",
        "request_pull.py",
        "request_reply.py",
        "request_report.py",
        "state_consistency_check.py",
        "version_advice.py",
    }
)


def _cleanup_stale_root_scripts(target_root, dry_run=False, force=False):
    """Remove PRD Plugin scripts that were copied to the repo-root scripts/
    directory by an older version of the installer.

    Only removes files whose names match known PRD Plugin script names. Any
    other file in the target scripts/ directory is left alone, so user
    scripts there are not deleted.
    """
    removed = []
    skipped = []
    root_scripts = target_root / "scripts"
    if not root_scripts.is_dir():
        return {"removed": removed, "skipped": skipped}
    for entry in root_scripts.iterdir():
        if not entry.is_file():
            continue
        if entry.name not in STALE_ROOT_SCRIPTS:
            continue
        if dry_run:
            removed.append(entry.name)
            continue
        entry.unlink()
        removed.append(entry.name)
    if not dry_run and root_scripts.is_dir():
        leftover = [p.name for p in root_scripts.iterdir() if p.is_file()]
        if not leftover:
            try:
                root_scripts.rmdir()
            except OSError:
                pass
    return {"removed": removed, "skipped": skipped}


def _configure_opencode_json(target_root, plugin_spec, options, dry_run=False):
    opencode_json = Path(target_root) / "opencode.json"
    existing = {}
    if opencode_json.is_file():
        try:
            existing = _read_json(opencode_json)
            if not isinstance(existing, dict):
                existing = {}
        except Exception:
            existing = {}

    plugins = existing.get("plugin", [])
    if not isinstance(plugins, list):
        plugins = []

    normalized = [p for p in plugins if isinstance(p, str)]
    needs_write = False

    def _spec_name(spec):
        return spec.strip().split("@")[0] if "@" in spec else spec.strip()

    def _spec_version(spec):
        if "@" not in spec:
            return None
        after = spec.split("@", 1)[1]
        # Strip any url fragment, e.g. #v0.5.36
        version = after.split("#", 1)[0]
        if version.startswith("git+") or version.startswith("file://") or "/" in version:
            return None
        return version

    # Check whether an existing prd-plugin entry has a stale version and
    # replace it. Also track the position so we can update in place.
    prd_index = None
    prd_present = False
    for i, spec in enumerate(normalized):
        if _spec_name(spec) == "prd-plugin":
            prd_present = True
            prd_index = i
            existing_version = _spec_version(spec)
            new_version = _spec_version(plugin_spec)
            if existing_version is not None and new_version is not None and existing_version != new_version:
                normalized[i] = plugin_spec
                needs_write = True
            break
    skills_present = any(_spec_name(p) == "opencode-agent-skills" for p in normalized)
    prd_permission_present = (
        isinstance(existing.get("permission"), dict)
        and isinstance(existing["permission"].get("skill"), dict)
        and existing["permission"]["skill"].get("prd-plugin") == "allow"
    )

    want_skills_inject = options.get("opencode-skill-inject", False)

    if not prd_present:
        normalized.append(plugin_spec)
        needs_write = True
    if want_skills_inject and not skills_present:
        normalized.append("opencode-agent-skills@0.7.0")
        needs_write = True
    if want_skills_inject and not prd_permission_present:
        permissions = existing.get("permission", {})
        if not isinstance(permissions, dict):
            permissions = {}
        skill_perms = permissions.get("skill", {})
        if not isinstance(skill_perms, dict):
            skill_perms = {}
        skill_perms["prd-plugin"] = "allow"
        permissions["skill"] = skill_perms
        existing["permission"] = permissions
        needs_write = True

    if needs_write:
        existing["plugin"] = normalized
        action = "would_create_or_update" if dry_run else "created_or_updated"
        if not dry_run:
            opencode_json.write_text(
                json.dumps(existing, indent=2) + "\n", encoding="utf-8"
            )
    else:
        action = "unchanged"

    return {
        "path": str(opencode_json.relative_to(target_root)).replace("\\", "/"),
        "plugin_spec": plugin_spec,
        "opencode_skill_inject": want_skills_inject,
        "action": action,
    }


CLAUDE_SETTINGS_REL = ".claude/settings.json"


def _detect_hook_interpreter():
    """Return a Python-3 interpreter token for generated hook commands.

    Downstream hosts vary: many non-Windows systems expose only ``python3``
    (or ``python`` is Python 2), while Windows usually has ``python`` and may
    expose a non-functional ``python3`` App Execution Alias. Prefer ``python3``,
    fall back to ``python``, and verify each candidate actually runs Python 3
    before choosing it so a broken alias or a Python 2 ``python`` is skipped.
    """
    import subprocess

    for candidate in ("python3", "python"):
        if not shutil.which(candidate):
            continue
        try:
            result = subprocess.run(
                [candidate, "-c", "import sys; sys.exit(0 if sys.version_info[0] >= 3 else 1)"],
                capture_output=True,
                stdin=subprocess.DEVNULL,
                timeout=15,
                check=False,
            )
        except (OSError, subprocess.SubprocessError):
            continue
        if result.returncode == 0:
            return candidate
    # Nothing probed clean (a pathological host with neither named token on
    # PATH). Default to the portable token most hosts expect rather than
    # guessing a broken interpreter.
    return "python3"


def _rewrite_hook_command(command, interpreter):
    """Rewrite the leading ``python``/``python3`` token of a hook command.

    Returns ``(new_command, changed)``. Commands that do not start with a
    Python interpreter token, or already use ``interpreter``, are returned
    unchanged. Leading whitespace and the rest of the command are preserved.
    """
    if not isinstance(command, str):
        return command, False
    stripped = command.lstrip()
    leading = command[: len(command) - len(stripped)]
    parts = stripped.split(" ", 1)
    token = parts[0] if parts else ""
    if token not in ("python", "python3") or token == interpreter:
        return command, False
    rest = parts[1] if len(parts) > 1 else ""
    new = leading + interpreter + ((" " + rest) if rest else "")
    return new, True


PLUGIN_MARKETPLACE_REPO = "markusuk1/prd-plugin"
PLUGIN_ENABLE_KEY = "prd-plugin@prd-plugin"
MCP_SERVER_SOURCE = "mcp/server.cjs"
MCP_SERVER_TARGET = ".prd_plugin/mcp/server.cjs"


def _detect_node():
    # shutil.which resolves .cmd/.exe shims on Windows, where a bare
    # ["node", ...] spawn can miss a shim-only install.
    return shutil.which("node") is not None


def _copy_mcp_server_files(hub_root, target_root):
    dest = Path(target_root) / MCP_SERVER_TARGET
    dest.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(Path(hub_root) / MCP_SERVER_SOURCE, dest)
    metadata = Path(hub_root) / MCP_METADATA_SOURCE
    if metadata.is_file():
        shutil.copy2(metadata, Path(target_root) / MCP_METADATA_TARGET)


def _install_mcp_server(hub_root, target_root, dry_run=False):
    """Copy the MCP state server and wire it into the repo's .mcp.json.

    The merge is additive and idempotent: existing servers are preserved, extra
    keys on an existing prd-plugin entry are kept, and an unparseable .mcp.json
    is never overwritten (the user's config is not ours to destroy). The write
    is skipped entirely when the entry is already correct.
    """
    source = Path(hub_root) / MCP_SERVER_SOURCE
    if not source.is_file():
        return {"installed": False, "reason": "server source missing in hub"}
    dest = Path(target_root) / MCP_SERVER_TARGET
    mcp_path = Path(target_root) / ".mcp.json"
    report = {"installed": True, "server": MCP_SERVER_TARGET, "node_found": _detect_node()}

    config = {}
    if mcp_path.is_file():
        try:
            config = json.loads(mcp_path.read_text(encoding="utf-8-sig"))
        except Exception:
            # Never clobber a file we cannot parse — fixing it is the user's call.
            report["mcp_json"] = "skipped_unparseable"
            if not dry_run:
                dest.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(source, dest)
            return report
    if not isinstance(config, dict):
        report["mcp_json"] = "skipped_not_an_object"
        if not dry_run:
            _copy_mcp_server_files(hub_root, target_root)
        return report

    servers = config.setdefault("mcpServers", {})
    if not isinstance(servers, dict):
        # mcpServers exists but is the wrong type — not ours to rewrite.
        report["mcp_json"] = "skipped_mcpServers_not_an_object"
        if not dry_run:
            _copy_mcp_server_files(hub_root, target_root)
        return report
    existing = servers.get("prd-plugin")
    entry = dict(existing) if isinstance(existing, dict) else {}
    entry["command"] = "node"
    entry["args"] = [MCP_SERVER_TARGET]
    changed = existing != entry
    servers["prd-plugin"] = entry
    report["mcp_json"] = "updated" if changed else "already_configured"
    if not dry_run:
        _copy_mcp_server_files(hub_root, target_root)
        if changed:
            mcp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
    return report


def _is_prd_hook_entry(hook):
    """Whether a single Codex hook ENTRY is one of ours.

    Ownership is read off the command itself rather than tracked in a manifest:
    every entry we ship runs prd_hook_dispatch, and a downstream repo's entries
    do not. That means we can refresh ours without a record of what we wrote
    last time, including for repos installed by older versions.

    Per ENTRY, not per group (REQ-158). Classifying whole groups was the first
    fix for REQ-155 and it was still destructive: a Codex group's `hooks` array
    may hold several entries, so "any entry is ours -> the group is ours"
    deleted a repo's hook that happened to sit beside the dispatcher. That is
    exactly what it did to ai-collab-v3, twice.
    """
    if not isinstance(hook, dict):
        return False
    return PRD_HOOK_MARKER in f"{hook.get('command', '')}{hook.get('commandWindows', '')}"


def _strip_prd_entries(group):
    """A copy of `group` with our hook entries removed, or None if nothing is left.

    Returning None rather than an empty group matters: a group whose `hooks`
    array is empty is noise Codex would still carry, and it was never the
    repo's configuration in the first place.
    """
    if not isinstance(group, dict):
        return group
    hooks = group.get("hooks")
    if not isinstance(hooks, list):
        return group
    kept = [h for h in hooks if not _is_prd_hook_entry(h)]
    if not kept:
        return None
    if len(kept) == len(hooks):
        return group
    stripped = dict(group)
    stripped["hooks"] = kept
    return stripped


def _foreign_groups(groups):
    """Every group with our entries stripped out, preserving the repo's order."""
    if not isinstance(groups, list):
        return []
    out = []
    for group in groups:
        kept = _strip_prd_entries(group)
        if kept is not None:
            out.append(kept)
    return out


def _merge_codex_hooks(hub_root, target_root, dry_run=False):
    """Refresh the plugin's Codex hooks while preserving the repo's own.

    `.codex/hooks.json` is not ours alone — Codex reads one hooks file per repo,
    so a downstream repo's hooks sit alongside the plugin's. Copying the
    skeleton over it DELETED them (REQ-155, reported by ai-collab-v3 after its
    context-frame hook silently stopped running). Ours are replaced, theirs are
    kept, and an unreadable file is never rewritten.
    """
    shipped_path = Path(hub_root) / SKELETON_DIR / CODEX_HOOKS_REL
    destination = Path(target_root) / CODEX_HOOKS_REL
    try:
        shipped = json.loads(shipped_path.read_text(encoding="utf-8-sig"))
    except (OSError, ValueError):
        return {"skipped": "shipped .codex/hooks.json is missing or invalid"}
    shipped_hooks = shipped.get("hooks")
    if not isinstance(shipped_hooks, dict):
        return {"skipped": "shipped .codex/hooks.json has no hooks object"}

    if not destination.is_file():
        if not dry_run:
            destination.parent.mkdir(parents=True, exist_ok=True)
            destination.write_text(json.dumps(shipped, indent=2) + "\n", encoding="utf-8")
        return {"hooks": "created", "preserved": 0}

    try:
        existing = json.loads(destination.read_text(encoding="utf-8-sig"))
    except (OSError, ValueError):
        # That file carries the repo's own configuration. If we cannot read it
        # we certainly cannot safely replace it.
        return {"skipped": ".codex/hooks.json is not valid JSON — left untouched; "
                           "fix it and re-run"}
    if not isinstance(existing, dict):
        return {"skipped": ".codex/hooks.json is not a JSON object — left untouched"}
    events = existing.get("hooks")
    if events is None:
        events = {}
    if not isinstance(events, dict):
        return {"skipped": ".codex/hooks.json hooks is not an object — left untouched"}

    merged = dict(events)
    preserved = 0
    for event, groups in list(merged.items()):
        if not isinstance(groups, list):
            continue
        foreign = _foreign_groups(groups)
        preserved += sum(len(g.get("hooks", [])) for g in foreign if isinstance(g, dict))
        if event in shipped_hooks:
            continue
        # We no longer ship this event: drop our stale entries, and drop the
        # event itself only if it was never anything but ours.
        if foreign:
            merged[event] = foreign
        else:
            del merged[event]
    for event, groups in shipped_hooks.items():
        merged[event] = _foreign_groups(merged.get(event)) + list(groups)

    result = dict(existing)
    result["hooks"] = merged
    changed = result != existing or "hooks" not in existing
    if changed and not dry_run:
        destination.parent.mkdir(parents=True, exist_ok=True)
        destination.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
    return {"hooks": "updated" if changed else "already_current", "preserved": preserved}


def _ensure_claude_plugin_config(settings_path, dry_run=False):
    """Add the repo-scoped prd-plugin marketplace + enablement to .claude/settings.json.

    Merges additively (preserves existing keys) so the plugin is enabled per-repo
    via the documented `extraKnownMarketplaces` + `enabledPlugins` schema. Native
    `/plugin enable|disable prd-plugin@prd-plugin` toggles it afterward.
    """
    settings = {}
    if settings_path.is_file():
        try:
            settings = json.loads(settings_path.read_text(encoding="utf-8-sig"))
        except Exception:
            # Never rewrite a settings.json we cannot parse — that file carries
            # the user's hooks and permissions.
            return {"skipped": "settings.json is not valid JSON — left untouched; fix it and re-run"}
    if not isinstance(settings, dict):
        return {"skipped": "settings.json is not a JSON object — left untouched"}
    markets = settings.setdefault("extraKnownMarketplaces", {})
    enabled = settings.setdefault("enabledPlugins", {})
    if not isinstance(markets, dict) or not isinstance(enabled, dict):
        # Valid JSON, wrong shapes — not ours to rewrite.
        return {"skipped": "extraKnownMarketplaces/enabledPlugins have unexpected types — left untouched"}
    market_entry = {"source": {"source": "github", "repo": PLUGIN_MARKETPLACE_REPO}}
    changed = markets.get("prd-plugin") != market_entry or enabled.get(PLUGIN_ENABLE_KEY) is not True
    markets["prd-plugin"] = market_entry
    enabled[PLUGIN_ENABLE_KEY] = True
    if changed and not dry_run:
        settings_path.parent.mkdir(parents=True, exist_ok=True)
        settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
    return {"marketplace": PLUGIN_MARKETPLACE_REPO, "enabled": PLUGIN_ENABLE_KEY,
            "settings": "updated" if changed else "already_configured"}


def _patch_claude_settings_interpreter(settings_path, interpreter, dry_run=False):
    """Rewrite hook interpreter tokens in a Claude Code settings.json.

    Walks ``hooks -> <event> -> <group> -> hooks -> command`` and replaces the
    leading Python interpreter token with ``interpreter``. Returns a report
    dict with ``interpreter``, ``patched`` (whether any command changed), and
    ``commands`` (the count of hook commands inspected).
    """
    report = {"interpreter": interpreter, "patched": False, "commands": 0}
    if not settings_path.is_file():
        report["path_missing"] = True
        return report
    try:
        settings = json.loads(settings_path.read_text(encoding="utf-8-sig"))
    except (json.JSONDecodeError, OSError):
        report["unreadable"] = True
        return report

    changed = False
    hooks = settings.get("hooks", {})
    if isinstance(hooks, dict):
        for event_groups in hooks.values():
            if not isinstance(event_groups, list):
                continue
            for group in event_groups:
                if not isinstance(group, dict):
                    continue
                for hook in group.get("hooks", []) or []:
                    if not isinstance(hook, dict) or hook.get("type") != "command":
                        continue
                    report["commands"] += 1
                    new_command, did = _rewrite_hook_command(hook.get("command"), interpreter)
                    if did:
                        hook["command"] = new_command
                        changed = True

    report["patched"] = changed
    if changed and not dry_run:
        settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
    return report


def resolve_service_identity(target_root):
    """Replace the service manifest's install placeholder with a real repo id
    (REQ-127).

    services.json is state-protected, so --force never rewrites it; that is
    correct for owner-declared content but left the template placeholder
    'auto' in place forever. Addressed routing and the workspace-boundary
    predicate both need a real id, so this narrow repair runs on install and
    update. Never overwrites a repository that already names itself.
    """
    try:
        sys.path.insert(0, str(Path(target_root) / ".prd_plugin" / "scripts"))
        import prd_services

        return prd_services.resolve_repository_identity(target_root)
    except Exception as exc:  # pragma: no cover - identity repair is never fatal
        return {"id": None, "changed": False, "reason": f"{type(exc).__name__}: {exc}"}


def flag_wiki_backfill_if_needed(target_root):
    """Flag an established repo that just gained the LLM wiki for a deep backfill.

    Lazy-init (first Ingest creates the wiki) suits fresh repos. An established
    repo already holds knowledge that should be captured in one deliberate pass,
    so drop a marker the nudge and prd_status surface and the project-llm-wiki
    Backfill mode acts on. No marker for fresh scaffolds, existing wikis, or when
    the wiki is disabled. Read-only except for the marker; never raises.
    """
    try:
        root = Path(target_root)
        config = _read_json(root / ".prd_plugin" / "config.json")
        if not isinstance(config, dict):
            return False
        wiki_cfg = config.get("knowledge", {}).get("llm_wiki", {})
        if not isinstance(wiki_cfg, dict) or wiki_cfg.get("enabled") is False:
            return False
        wiki_dir = wiki_cfg.get("wiki_dir", "wiki")
        if (root / wiki_dir / "index.md").is_file():
            return False  # a real wiki already exists

        # "Established" = has real project content beyond plugin/vendor plumbing
        # and generated report/output trees (kept in sync with prd_wiki_backfill).
        skip = SKIP_DIRS_FOR_ESTABLISHED
        established = False
        for path in root.rglob("*"):
            if not path.is_file():
                continue
            rel = path.relative_to(root)
            parts = set(rel.parts)
            if parts & skip:
                continue
            if any(rel.parts[: len(prefix)] == prefix
                   for prefix in SKIP_PATH_PREFIXES_FOR_ESTABLISHED):
                continue
            if path.suffix.lower() in {".py", ".js", ".ts", ".tsx", ".jsx", ".rs",
                                       ".go", ".java", ".rb", ".c", ".cpp", ".cs",
                                       ".md", ".rst"} and path.name != "README.md":
                established = True
                break
        # a README alone is not "established"; real source or docs are
        if not established:
            return False

        marker = root / ".prd_plugin" / "local" / "wiki-backfill-needed"
        if marker.exists():
            return True
        marker.parent.mkdir(parents=True, exist_ok=True)
        marker.write_text(
            "This repo gained the LLM wiki on a plugin update but has no wiki yet.\n"
            "Run a deep backfill: python .prd_plugin/scripts/prd_wiki_backfill.py --plan\n"
            "then compile via the project-llm-wiki skill's Backfill mode and delete "
            "this file.\n",
            encoding="utf-8",
        )
        return True
    except Exception:
        return False


SKIP_DIRS_FOR_ESTABLISHED = {
    ".git", ".hg", ".svn", "node_modules", ".venv", "venv", "__pycache__",
    ".prd_plugin", ".claude", ".agents", ".opencode", ".codex", ".codex-plugin",
    ".claude-plugin", "dist", "build", "out", "target", ".next", ".cache",
    "wiki", "raw", "coverage", ".pytest_cache", ".mypy_cache",
    "request-report",
}
SKIP_PATH_PREFIXES_FOR_ESTABLISHED = {
    ("docs", "evidence"),
}


def _record_install(target_root, version, host_agents, installed_scripts, force):
    project_path = Path(target_root) / ".prd_plugin" / "state" / "project.json"
    if project_path.exists():
        data = _read_json(project_path)
    else:
        data = {"schema_version": "0.1", "plugin": {}}
    if not isinstance(data, dict):
        data = {"schema_version": "0.1", "plugin": {}}
    plugin = data.setdefault("plugin", {})
    if not isinstance(plugin, dict):
        plugin = {}
        data["plugin"] = plugin
    plugin["installed_version"] = version
    plugin["installed_from"] = str(_hub_root())
    plugin["installed_at"] = datetime.now(timezone.utc).isoformat()
    plugin["installed_by"] = "prd_install.py"
    plugin["host_agents"] = host_agents
    plugin["installed_scripts"] = sorted(installed_scripts)
    project_path.parent.mkdir(parents=True, exist_ok=True)
    project_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")

    _stamp_config_version(target_root, version)


def read_config_snapshot(target_root):
    """The target's config.json as data, or {} when absent/unreadable.

    Used to bracket an install so it can report what it actually changed.
    """
    path = Path(target_root) / ".prd_plugin" / "config.json"
    if not path.is_file():
        return {}
    try:
        data = _read_json(path)
    except Exception:
        return {}
    return data if isinstance(data, dict) else {}


def _flatten_config(data, prefix=""):
    """Dotted leaf paths. Lists are leaves: reporting `k.l[3]` is noise when the
    operator wants to know which SETTING moved."""
    out = {}
    if not isinstance(data, dict):
        return out
    for key, value in data.items():
        path = f"{prefix}.{key}" if prefix else str(key)
        if isinstance(value, dict):
            out.update(_flatten_config(value, path))
        else:
            out[path] = value
    return out


def config_diff(before, after):
    """What changed between two config.json snapshots, as ordered records.

    Several installer steps mutate config.json independently (the version
    stamp, the reflection migration, …) and none of them said so. When an
    install and a profile change touched the same file, the changes could not
    be attributed afterwards (REQ-152). Every install now reports this.

    Never raises: an unparseable config degrades to "no reported changes"
    rather than failing the install.
    """
    old = _flatten_config(before)
    new = _flatten_config(after)
    changes = []
    for key in sorted(set(old) | set(new)):
        was, now = old.get(key), new.get(key)
        if key not in new:
            changes.append({"key": key, "from": was, "to": None, "removed": True})
        elif key not in old:
            changes.append({"key": key, "from": None, "to": now, "added": True})
        elif was != now:
            changes.append({"key": key, "from": was, "to": now})
    return changes


def _stamp_config_version(target_root, version):
    """Refresh .prd_plugin/config.json's plugin.installed_version marker.

    config.json is state-protected, so `--force` preserves it and the skeleton
    copy never rewrites it -- but installed_version is a plugin-owned marker, not
    user configuration, and it is the field the version check reads
    (prd_version_check.installed_version). Left unstamped it goes stale on every
    update and silently masks releases (REQ-080). Surgically update just that
    field, preserving all other user config, and only when the value changed so
    the file's style is otherwise untouched.
    """
    config_path = Path(target_root) / ".prd_plugin" / "config.json"
    if not config_path.exists():
        return
    data = _read_json(config_path)
    if not isinstance(data, dict):
        return
    plugin = data.get("plugin")
    if not isinstance(plugin, dict):
        plugin = {}
        data["plugin"] = plugin
    if plugin.get("installed_version") == version:
        return
    plugin["installed_version"] = version
    config_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")


def _ensure_reflection_config(hub_root, target_root, dry_run=False):
    """Add the reflection contract to preserved configs without replacing it.

    Upgrade installs intentionally protect config.json and registry.json. That
    must not strand older downstream repos without new configuration/ID
    prefixes, so this performs a bounded additive migration: install the
    shipped defaults only when ``reflection`` is absent, preserve any valid
    existing custom bank byte-for-data, add RFQ to required prefixes, and move
    the registry cursor beyond existing questions. Malformed inputs are never
    overwritten.
    """
    target_root = Path(target_root)
    config_path = target_root / ".prd_plugin" / "config.json"
    registry_path = target_root / ".prd_plugin" / "ids" / "registry.json"
    source_path = Path(hub_root) / "templates" / "config.json"
    try:
        source = _read_json(source_path)
        defaults = json.loads(json.dumps(source["reflection"]))
        validate_reflection(defaults)
    except Exception as exc:
        return {"config": "skipped_invalid_source", "reason": str(exc)}

    if not config_path.is_file():
        if dry_run:
            return {"config": "would_install_defaults", "registry": "would_install_rfq_cursor"}
        return {"config": "skipped_missing_config", "registry": "skipped_missing_config"}
    try:
        config = _read_json(config_path)
    except Exception as exc:
        return {"config": "skipped_unparseable", "reason": str(exc)}
    if not isinstance(config, dict):
        return {"config": "skipped_not_an_object"}

    existing = config.get("reflection")
    if existing is not None:
        try:
            validate_reflection(existing)
        except Exception as exc:
            return {"config": "skipped_invalid_custom", "reason": str(exc)}
        reflection = existing
        config_action = "preserved_custom"
    else:
        reflection = defaults
        config["reflection"] = reflection
        config_action = "would_add_defaults" if dry_run else "added_defaults"

    ids = config.get("ids")
    if not isinstance(ids, dict):
        ids = {"zero_pad": 3, "required_prefixes": []}
        config["ids"] = ids
    prefixes = ids.get("required_prefixes")
    if not isinstance(prefixes, list):
        return {"config": "skipped_invalid_ids", "reason": "ids.required_prefixes must be an array"}
    prefix_added = "RFQ" not in prefixes
    if prefix_added:
        prefixes.append("RFQ")

    highest = 0
    for category in reflection["categories"]:
        for question in category["questions"]:
            match = re.fullmatch(r"RFQ-(\d+)", question["id"])
            if match:
                highest = max(highest, int(match.group(1)))

    if not registry_path.is_file():
        return {"config": "skipped_missing_registry", "reason": str(registry_path)}
    try:
        registry = _read_json(registry_path)
    except Exception as exc:
        return {"config": "skipped_invalid_registry", "reason": str(exc)}
    next_map = registry.get("next") if isinstance(registry, dict) else None
    if not isinstance(next_map, dict):
        return {"config": "skipped_invalid_registry", "reason": "registry next must be an object"}
    current = next_map.get("RFQ", 1)
    if isinstance(current, bool) or not isinstance(current, int) or current < 1:
        return {"config": "skipped_invalid_registry", "reason": "registry next.RFQ must be positive"}
    desired = max(current, highest + 1)
    registry_changed = next_map.get("RFQ") != desired
    next_map["RFQ"] = desired

    if not dry_run:
        if existing is None or prefix_added:
            config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
        if registry_changed:
            registry_path.write_text(json.dumps(registry, indent=2) + "\n", encoding="utf-8")
    return {
        "config": config_action,
        "rfq_prefix": "would_add" if dry_run and prefix_added else ("added" if prefix_added else "already_configured"),
        "registry": ("would_set" if dry_run and registry_changed else
                     "updated" if registry_changed else "already_configured"),
        "next_rfq": desired,
    }


def _ensure_substrate_config(hub_root, target_root, dry_run=False):
    """Add the Substrate adapter defaults to a preserved downstream config.

    Upgrade installs protect ``.prd_plugin/config.json``. New integration
    contracts therefore need an additive migration so their master switch is
    visible and editable after an upgrade. Existing adapter configuration is
    user-owned and is preserved without modification.
    """
    config_path = Path(target_root) / ".prd_plugin" / "config.json"
    source_path = Path(hub_root) / "templates" / "config.json"
    try:
        source = _read_json(source_path)
        defaults = json.loads(json.dumps(source["integrations"]["substrate"]))
    except Exception as exc:
        return {"config": "skipped_invalid_source", "reason": str(exc)}

    if not config_path.is_file():
        return {"config": "skipped_missing_config"}
    try:
        config = _read_json(config_path)
    except Exception as exc:
        return {"config": "skipped_unparseable", "reason": str(exc)}
    if not isinstance(config, dict):
        return {"config": "skipped_not_an_object"}

    integrations = config.get("integrations")
    if integrations is None:
        integrations = {}
        config["integrations"] = integrations
    elif not isinstance(integrations, dict):
        return {"config": "skipped_invalid_integrations"}

    existing = integrations.get("substrate")
    if existing is not None:
        if not isinstance(existing, dict):
            return {"config": "skipped_invalid_custom"}
        return {"config": "preserved_custom"}

    integrations["substrate"] = defaults
    if not dry_run:
        config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
    return {"config": "would_add_defaults" if dry_run else "added_defaults"}


def _ensure_verification_config(hub_root, target_root, dry_run=False):
    """Add test-scope defaults to a preserved downstream config.

    Existing verification policy is user-owned and preserved exactly. Older
    installs receive the disabled-by-default shipped contract additively.
    """
    config_path = Path(target_root) / ".prd_plugin" / "config.json"
    source_path = Path(hub_root) / "templates" / "config.json"
    try:
        source = _read_json(source_path)
        defaults = json.loads(json.dumps(source["verification"]))
    except Exception as exc:
        return {"config": "skipped_invalid_source", "reason": str(exc)}

    if not config_path.is_file():
        return {"config": "skipped_missing_config"}
    try:
        config = _read_json(config_path)
    except Exception as exc:
        return {"config": "skipped_unparseable", "reason": str(exc)}
    if not isinstance(config, dict):
        return {"config": "skipped_not_an_object"}

    existing = config.get("verification")
    if existing is not None:
        if not isinstance(existing, dict):
            return {"config": "skipped_invalid_custom"}
        test_scope = existing.get("test_scope")
        if test_scope is not None:
            if not isinstance(test_scope, dict):
                return {"config": "skipped_invalid_custom"}
            return {"config": "preserved_custom"}
        existing["test_scope"] = defaults["test_scope"]
        if not dry_run:
            config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
        return {"config": "would_add_defaults" if dry_run else "added_defaults"}

    config["verification"] = defaults
    if not dry_run:
        config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
    return {"config": "would_add_defaults" if dry_run else "added_defaults"}


def _merge_missing_config(target, defaults, prefix, added):
    """Recursively add shipped keys while preserving every existing value."""
    for key, default in defaults.items():
        dotted = f"{prefix}.{key}" if prefix else key
        if key not in target:
            target[key] = json.loads(json.dumps(default))
            added.append(dotted)
        elif isinstance(target[key], dict) and isinstance(default, dict):
            _merge_missing_config(target[key], default, dotted, added)


def _ensure_unified_config(hub_root, target_root, dry_run=False):
    """Add unified controls, request routing, and workflow identity safely."""
    config_path = Path(target_root) / ".prd_plugin" / "config.json"
    source_path = Path(hub_root) / "templates" / "config.json"
    try:
        config = _read_json(config_path)
        source = _read_json(source_path)
    except Exception as exc:
        return {"config": "unavailable", "added": [], "reason": str(exc)}
    if not isinstance(config, dict) or not isinstance(source, dict):
        return {"config": "unavailable", "added": []}

    added = []
    invalid = []
    for section in ("configuration", "hooks", "workflows", "reasoning_guard"):
        defaults = source.get(section)
        if not isinstance(defaults, dict):
            continue
        existing = config.get(section)
        if existing is None:
            config[section] = json.loads(json.dumps(defaults))
            added.append(section)
        elif isinstance(existing, dict):
            _merge_missing_config(existing, defaults, section, added)
        else:
            invalid.append(section)

    # This workspace has one fixed PRD Plugin hub. Older installs shipped this
    # route as null, which meant prd_file_request could only park packages in
    # the originating repo's outbox. Repair only the old null/missing default;
    # a non-null repository-owned override remains untouched.
    default_requests = source.get("requests")
    existing_requests = config.get("requests")
    if isinstance(default_requests, dict) and isinstance(existing_requests, dict):
        default_hub = default_requests.get("upstream_hub_path")
        if existing_requests.get("upstream_hub_path") is None and default_hub:
            existing_requests["upstream_hub_path"] = json.loads(json.dumps(default_hub))
            added.append("requests.upstream_hub_path")
    elif isinstance(default_requests, dict) and existing_requests is None:
        config["requests"] = json.loads(json.dumps(default_requests))
        added.append("requests")
    elif existing_requests is not None:
        invalid.append("requests")

    ids = config.get("ids")
    prefixes = ids.get("required_prefixes") if isinstance(ids, dict) else None
    if isinstance(prefixes, list):
        if "WFR" not in prefixes:
            prefixes.append("WFR")
            added.append("ids.required_prefixes.WFR")
    else:
        invalid.append("ids.required_prefixes")

    registry_path = Path(target_root) / ".prd_plugin" / "ids" / "registry.json"
    registry_changed = False
    registry = None
    try:
        registry = _read_json(registry_path)
        next_map = registry.get("next") if isinstance(registry, dict) else None
        if not isinstance(next_map, dict):
            invalid.append("registry.next")
        elif "WFR" not in next_map:
            next_map["WFR"] = 1
            registry_changed = True
    except Exception:
        invalid.append("registry.next")

    if not added and not registry_changed:
        status = "invalid_existing" if invalid else "preserved_custom"
        return {"config": status, "added": [], "invalid": invalid, "wfr_registry": "preserved"}
    if dry_run:
        return {"config": "would_add_defaults", "added": added, "invalid": invalid,
                "wfr_registry": "would_add" if registry_changed else "preserved"}
    if added:
        config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
    if registry_changed:
        registry_path.write_text(json.dumps(registry, indent=2) + "\n", encoding="utf-8")
    return {"config": "added_defaults", "added": added, "invalid": invalid,
            "wfr_registry": "added" if registry_changed else "preserved"}


def _resolve_options(args):
    """Normalize CLI args into a dict of install options.

    Returns a dict with keys: codex, opencode, claude, claude_skills,
    and opencode-skill-inject. The deprecated --target-agent flag is still
    honored for backward compatibility.
    """
    options = {
        "codex": getattr(args, "codex", None),
        "opencode": getattr(args, "opencode", None),
        "claude": getattr(args, "claude", None),
        "claude_skills": getattr(args, "claude_skills", False),
        "opencode-skill-inject": getattr(args, "opencode_skill_inject", None),
    }

    target_agent = getattr(args, "target_agent", None)
    if target_agent is not None and not getattr(args, "_target_agent_explicit", False):
        if options["codex"] is None and options["opencode"] is None:
            if target_agent == "codex":
                options["codex"] = True
                options["opencode"] = False
            elif target_agent == "opencode":
                options["codex"] = False
                options["opencode"] = True
            elif target_agent == "both":
                options["codex"] = True
                options["opencode"] = True

    if options["codex"] is None:
        options["codex"] = True
    if options["opencode"] is None:
        options["opencode"] = True
    if options["claude"] is None:
        options["claude"] = True
    if options["opencode-skill-inject"] is None:
        options["opencode-skill-inject"] = options["opencode"]

    return options


def _load_skill_scopes_with_options(hub_root, options):
    """Read skill-install-scope.json and filter by active options."""
    manifest_path = Path(hub_root) / SKILL_SCOPE_MANIFEST
    if not manifest_path.exists():
        return {}
    data = _read_json(manifest_path)
    skills = data.get("skills", {})
    if not isinstance(skills, dict):
        return {}

    active = {k for k, v in options.items() if v}
    filtered = {}
    for name, metadata in skills.items():
        if not isinstance(metadata, dict):
            continue
        if metadata.get("install_scope") not in ("downstream_runtime", "downstream_optional"):
            continue
        required = metadata.get("requires_options") or ["codex", "opencode"]
        if not isinstance(required, list):
            required = ["codex", "opencode"]
        if any(req in active for req in required):
            filtered[name] = metadata
    return filtered


def _load_script_scopes_with_options(hub_root, options, include_optional=False):
    """Read script-install-scope.json and filter by active options."""
    manifest_path = Path(hub_root) / SCRIPT_SCOPE_MANIFEST
    if not manifest_path.exists():
        return {}
    data = _read_json(manifest_path)
    scripts = data.get("scripts", {})
    if not isinstance(scripts, dict):
        return {}

    active = {k for k, v in options.items() if v}
    scopes = {"downstream_runtime", "downstream_optional"} if include_optional else {"downstream_runtime"}
    filtered = {}
    for name, metadata in scripts.items():
        if not isinstance(metadata, dict):
            continue
        if metadata.get("install_scope") not in scopes:
            continue
        required = metadata.get("requires_options") or ["codex", "opencode"]
        if not isinstance(required, list):
            required = ["codex", "opencode"]
        if any(req in active for req in required):
            filtered[name] = metadata.get("install_scope")
    return filtered


def install_prd_plugin(
    target_repo,
    hub_root=None,
    target_agent="codex",
    include_optional_scripts=False,
    force=False,
    dry_run=False,
    opencode_plugin_spec=None,
    options=None,
    yes=False,
):
    if options is None:
        options = {
            "codex": target_agent in ("codex", "both"),
            "opencode": target_agent in ("opencode", "both"),
            "claude": target_agent == "claude",
            "claude_skills": False,
            "opencode-skill-inject": target_agent in ("opencode", "both"),
        }
    options.setdefault("claude_skills", False)

    hub_root = Path(hub_root or _hub_root()).resolve()
    target_root = Path(target_repo).resolve()

    if not hub_root.is_dir():
        raise FileNotFoundError(f"PRD Plugin hub root not found: {hub_root}")
    if target_root == hub_root or hub_root in target_root.parents:
        raise ValueError("Cannot install PRD Plugin into the hub repo itself.")
    if _is_hub_repo(target_root):
        raise ValueError(
            "Target repo looks like the PRD Plugin hub. "
            "The installer is meant for downstream repositories only."
        )

    version = _detect_installed_version(hub_root)
    if not version:
        raise RuntimeError("Could not detect PRD Plugin version from hub manifests.")

    target_agent = "both" if (options["codex"] and options["opencode"]) else (
        "codex" if options["codex"] else ("opencode" if options["opencode"] else "none")
    )
    # Claude is plugin-primary: it only joins host_agents (project .claude/skills)
    # when the --claude-skills escape hatch is set. codex/opencode are unchanged.
    host_agents = [h for h in ("codex", "opencode") if options.get(h)]
    if claude_skills_opted_in(target_root, options):
        host_agents.append("claude")
    skeleton_skip = _skeleton_skip_prefixes(options)

    # Bracket the install so it can say exactly what it changed in config.json.
    # Several steps mutate that file independently and none of them reported it,
    # so an install and a profile change could not be told apart afterwards
    # (REQ-152).
    config_before = read_config_snapshot(target_root)

    report = {
        "status": "dry_run" if dry_run else "installed",
        "hub_root": str(hub_root),
        "target_repo": str(target_root),
        "version": version,
        "options": options,
        "target_agent": target_agent,
    }
    report["gitignore"] = _ensure_downstream_gitignore(
        target_root / ".gitignore", dry_run=dry_run
    )

    if dry_run:
        overwrites = _find_state_overwrites(hub_root, target_root)
        skeleton_actions = _copy_skeleton(hub_root, target_root, force=force,
                                          allow_state=yes, dry_run=True,
                                          skip_prefixes=skeleton_skip)
        report["skeleton"] = {
            "would_copy": [a["path"] for a in skeleton_actions if a["action"] == "copied"],
            "would_skip": [a["path"] for a in skeleton_actions if a["action"] == "skipped"],
            "would_preserve": [a["path"] for a in skeleton_actions if a["action"] == "preserved"],
        }
        report["reflection_config"] = _ensure_reflection_config(
            hub_root, target_root, dry_run=True
        )
        report["substrate_config"] = _ensure_substrate_config(
            hub_root, target_root, dry_run=True
        )
        report["verification_config"] = _ensure_verification_config(
            hub_root, target_root, dry_run=True
        )
        report["unified_config"] = _ensure_unified_config(
            hub_root, target_root, dry_run=True
        )
        report["substrate_capability_catalog"] = _ensure_substrate_capability_catalog(
            hub_root, target_root, dry_run=True
        )
        report["tool_spec"] = _ensure_tool_spec(hub_root, target_root, dry_run=True)
        report["tool_surface_catalog"] = _ensure_tool_surface_catalog(
            hub_root, target_root, dry_run=True
        )
        interpreter = _detect_hook_interpreter()
        report["claude_settings"] = _patch_claude_settings_interpreter(
            hub_root / SKELETON_DIR / CLAUDE_SETTINGS_REL, interpreter, dry_run=True
        )
        if overwrites:
            report["state_overwrite_warning"] = {
                "would_overwrite": overwrites,
                "message": (
                    "A normal --force update preserves these files. --yes requests "
                    "a destructive reset and requires explicit interactive confirmation."
                ),
            }
        script_scopes = _load_script_scopes_with_options(
            hub_root, options, include_optional=include_optional_scripts
        )
        script_report = _install_scripts(hub_root, target_root, set(script_scopes.values()), force=False, dry_run=True)
        report["scripts"] = {
            "would_install": script_report["installed"],
            "would_skip": script_report["skipped"],
        }
        skill_report = install_skills(
            repo_root=target_root,
            source_skills_dir=hub_root / "skills",
            manifest_path=hub_root / SKILL_SCOPE_MANIFEST,
            active_options=options,
            dry_run=True,
            target_agent=target_agent,
            host_agents=host_agents,
        )
        report["skills"] = {
            "would_install": skill_report.get("would_install", []),
            "would_remove": skill_report.get("removed", []),
        }
        if options["opencode"]:
            spec = _detect_opencode_plugin_spec(hub_root, version, opencode_plugin_spec)
            report["opencode"] = _configure_opencode_json(
                target_root, spec, options, dry_run=True
            )
        # Disclose the MCP wiring too — a real run creates .prd_plugin/mcp/ and
        # touches .mcp.json at the repo root.
        report["mcp"] = _install_mcp_server(hub_root, target_root, dry_run=True)
        # ...and the per-repo plugin enablement written into .claude/settings.json.
        if options.get("claude"):
            report["claude_plugin_config"] = _ensure_claude_plugin_config(
                target_root / CLAUDE_SETTINGS_REL, dry_run=True
            )
        if options["codex"]:
            report["codex_hooks"] = _merge_codex_hooks(hub_root, target_root, dry_run=True)
        report["stale_root_scripts"] = _cleanup_stale_root_scripts(
            target_root, dry_run=True, force=force
        )
        return report

    # --force refreshes the plugin's own files while PRESERVING the repo's state
    # and config; only --yes additionally resets state/config to skeleton defaults
    # (a destructive reset). No abort: the update path must always be able to
    # refresh plugin files without losing data (REQ-075).
    if force and not yes:
        overwrites = _find_state_overwrites(hub_root, target_root)
        if overwrites:
            print(
                "PRD Plugin safe update: preserving existing state/config and "
                "refreshing plugin files only. Never add --yes to a routine update; "
                "it requests a destructive reset."
            )

    skeleton_actions = _copy_skeleton(hub_root, target_root, force=force,
                                      allow_state=yes, dry_run=False,
                                      skip_prefixes=skeleton_skip)
    report["skeleton"] = {
        "copied": [a["path"] for a in skeleton_actions if a["action"] == "copied"],
        "skipped": [a["path"] for a in skeleton_actions if a["action"] == "skipped"],
        "preserved": [a["path"] for a in skeleton_actions if a["action"] == "preserved"],
    }
    report["reflection_config"] = _ensure_reflection_config(
        hub_root, target_root, dry_run=False
    )
    report["substrate_config"] = _ensure_substrate_config(
        hub_root, target_root, dry_run=False
    )
    report["verification_config"] = _ensure_verification_config(
        hub_root, target_root, dry_run=False
    )
    report["unified_config"] = _ensure_unified_config(
        hub_root, target_root, dry_run=False
    )
    report["substrate_capability_catalog"] = _ensure_substrate_capability_catalog(
        hub_root, target_root, dry_run=False
    )
    report["tool_spec"] = _ensure_tool_spec(hub_root, target_root)
    report["tool_surface_catalog"] = _ensure_tool_surface_catalog(
        hub_root, target_root, dry_run=False
    )

    # Rewrite the Claude Code hook commands to a runnable interpreter on this
    # host. Only patch the settings.json we actually wrote this run (copied, or
    # overwritten under --force); a pre-existing file the user owns is left
    # alone. Re-run with --force to refresh an existing install.
    interpreter = _detect_hook_interpreter()
    settings_copied = CLAUDE_SETTINGS_REL in report["skeleton"]["copied"]
    if settings_copied:
        report["claude_settings"] = _patch_claude_settings_interpreter(
            target_root / CLAUDE_SETTINGS_REL, interpreter, dry_run=False
        )
    else:
        report["claude_settings"] = {
            "interpreter": interpreter,
            "patched": False,
            "skipped_existing": True,
        }

    # Enable the plugin per-repo (committed, project scope) via the documented
    # marketplace + enablement schema. Only for Claude installs.
    if options.get("claude"):
        report["claude_plugin_config"] = _ensure_claude_plugin_config(
            target_root / CLAUDE_SETTINGS_REL, dry_run=False
        )

    if options["codex"]:
        report["codex_hooks"] = _merge_codex_hooks(hub_root, target_root, dry_run=False)

    # MCP state server: validated write tools for every host that speaks MCP.
    report["mcp"] = _install_mcp_server(hub_root, target_root, dry_run=False)

    script_scopes = _load_script_scopes_with_options(
        hub_root, options, include_optional=include_optional_scripts
    )
    script_report = _install_scripts(
        hub_root, target_root, set(script_scopes.values()), force=force, dry_run=False
    )
    report["scripts"] = script_report

    skill_report = install_skills(
        repo_root=target_root,
        source_skills_dir=hub_root / "skills",
        manifest_path=hub_root / SKILL_SCOPE_MANIFEST,
        active_options=options,
        dry_run=False,
        target_agent=target_agent,
        host_agents=host_agents,
    )
    report["skills"] = {
        "installed": skill_report.get("installed", []),
        "removed": skill_report.get("removed", []),
    }

    if options["opencode"]:
        spec = _detect_opencode_plugin_spec(hub_root, version, opencode_plugin_spec)
        report["opencode"] = _configure_opencode_json(
            target_root, spec, options, dry_run=False
        )

    report["stale_root_scripts"] = _cleanup_stale_root_scripts(
        target_root, dry_run=False, force=force
    )

    _record_install(
        target_root,
        version,
        host_agents,
        script_report["installed"],
        force,
    )
    report["wiki_backfill_flagged"] = flag_wiki_backfill_if_needed(target_root)
    report["service_identity"] = resolve_service_identity(target_root)
    report["config_changes"] = config_diff(config_before,
                                          read_config_snapshot(target_root))

    return report


def main(argv=None):
    parser = argparse.ArgumentParser(
        description="Install PRD Plugin into a downstream repository from the plugin hub."
    )
    parser.add_argument(
        "target_repo",
        help="Root of the downstream repository to install PRD Plugin into.",
    )
    parser.add_argument(
        "--hub-root",
        default=str(_hub_root()),
        help="Path to the PRD Plugin hub repo. Defaults to the repo containing this script.",
    )
    parser.add_argument(
        "--codex",
        action="store_true",
        default=None,
        help="Install Codex skill discovery under .agents/skills/. Default: on.",
    )
    parser.add_argument(
        "--no-codex",
        action="store_true",
        help="Skip Codex skill discovery.",
    )
    parser.add_argument(
        "--opencode",
        action="store_true",
        default=None,
        help="Install opencode skill discovery under .opencode/skill/ and write opencode.json. Default: on.",
    )
    parser.add_argument(
        "--no-opencode",
        action="store_true",
        help="Skip opencode skill discovery.",
    )
    parser.add_argument(
        "--claude",
        action="store_true",
        default=None,
        help="Enable Claude Code support (plugin-primary: marketplace + enabledPlugins "
             "in .claude/settings.json; skills come from the enabled plugin). Also "
             "installs .claude/commands and hooks. Default: on.",
    )
    parser.add_argument(
        "--no-claude",
        action="store_true",
        help="Skip Claude Code support.",
    )
    parser.add_argument(
        "--claude-skills",
        action="store_true",
        default=False,
        help="Also copy project-level skills into .claude/skills/. By default Claude "
             "is plugin-primary (skills come from the enabled plugin); use this for "
             "repos that want a repo-pinned project copy instead.",
    )
    parser.add_argument(
        "--opencode-skill-inject",
        action="store_true",
        default=None,
        help="Add opencode-agent-skills plugin and prd-plugin skill permission to opencode.json. Default: on when --opencode is on.",
    )
    parser.add_argument(
        "--no-opencode-skill-inject",
        action="store_true",
        help="Do not add opencode-agent-skills plugin and prd-plugin skill permission to opencode.json.",
    )
    parser.add_argument(
        "--target-agent",
        choices=("codex", "opencode", "both"),
        default=None,
        help=(
            "Deprecated. Use --codex and --opencode instead. When set, "
            "maps codex->--codex, opencode->--opencode, both->both."
        ),
    )
    parser.add_argument(
        "--include-optional-scripts",
        action="store_true",
        help="Also install downstream_optional helper scripts (e.g. prd_doctor.py, message_check.py).",
    )
    parser.add_argument(
        "--force",
        action="store_true",
        help=(
            "Safely refresh plugin-owned files while preserving PRD Plugin state and config."
        ),
    )
    parser.add_argument(
        "--yes",
        action="store_true",
        help=(
            "Request a destructive reset of protected state/config when used with --force. "
            "Existing data triggers a separate interactive confirmation; never use this "
            "flag for a normal update."
        ),
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Report what would be installed without writing any files.",
    )
    parser.add_argument(
        "--opencode-plugin-spec",
        default=None,
        help="Opencode plugin spec to write into opencode.json. Defaults to the hub git remote.",
    )

    args = parser.parse_args(argv)

    if args.codex and args.no_codex:
        parser.error("--codex and --no-codex are mutually exclusive")
    if args.opencode and args.no_opencode:
        parser.error("--opencode and --no-opencode are mutually exclusive")
    if args.claude and args.no_claude:
        parser.error("--claude and --no-claude are mutually exclusive")
    if args.opencode_skill_inject and args.no_opencode_skill_inject:
        parser.error(
            "--opencode-skill-inject and --no-opencode-skill-inject are mutually exclusive"
        )
    if args.yes and not args.force:
        parser.error("--yes is only valid with --force and requests a destructive state reset")

    if args.force and args.yes and not args.dry_run:
        overwrites = _find_state_overwrites(
            Path(args.hub_root).resolve(), Path(args.target_repo).resolve()
        )
        if overwrites and not _prompt_for_state_overwrite(overwrites):
            print(
                "error: destructive reset cancelled; remove --yes for the safe update route",
                file=sys.stderr,
            )
            return 1

    codex = False if args.no_codex else (True if args.codex else None)
    opencode = False if args.no_opencode else (True if args.opencode else None)
    claude = False if args.no_claude else (True if args.claude else None)
    opencode_skill_inject = (
        False
        if args.no_opencode_skill_inject
        else (True if args.opencode_skill_inject else None)
    )

    target_agent = args.target_agent
    if target_agent is not None and (
        codex is not None or opencode is not None or claude is not None
    ):
        parser.error(
            "--target-agent cannot be combined with --codex/--opencode/--claude flags. "
            "Use one or the other."
        )
    if target_agent is not None:
        # The deprecated --target-agent flag predates Claude Code support, so it
        # never enables the claude host. Use --claude for that.
        claude = False
        if target_agent == "codex":
            codex = True
            opencode = False
        elif target_agent == "opencode":
            codex = False
            opencode = True
        elif target_agent == "both":
            codex = True
            opencode = True

    options = {
        "codex": codex if codex is not None else True,
        "opencode": opencode if opencode is not None else True,
        "claude": claude if claude is not None else True,
        "claude_skills": getattr(args, "claude_skills", False),
        "opencode-skill-inject": (
            opencode_skill_inject
            if opencode_skill_inject is not None
            else (opencode if opencode is not None else True)
        ),
    }

    try:
        report = install_prd_plugin(
            target_repo=args.target_repo,
            hub_root=args.hub_root,
            include_optional_scripts=args.include_optional_scripts,
            force=args.force,
            dry_run=args.dry_run,
            opencode_plugin_spec=args.opencode_plugin_spec,
            options=options,
            yes=args.yes,
        )
    except (FileNotFoundError, ValueError, RuntimeError, StateOverwriteError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1

    print(json.dumps(report, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
