"""Install / status / uninstall — wiring the shim into a repo's ``.claude/settings.json``.

**One implementation, thin shims (SHP-09).** The hook logic lives exactly once, in
this installed package. Adoption **copies nothing** into the target repo: no scripts,
no vendored kit, no second copy to drift. The only thing written is a one-line command
in ``.claude/settings.json`` that reaches the installed entrypoint::

    command -v mgf-fed >/dev/null 2>&1 && mgf-fed hooks session-start; exit 0

Every clause of that line is load-bearing. The ``command -v`` guard means a repo whose
environment does not have ``mgf-fed`` starts normally instead of printing an error
every session. The trailing ``; exit 0`` means the hook is fail-open even if the
entrypoint itself dies: a session is never unstartable because of us.

**Merge, never clobber.** ``settings.json`` is the user's file, shared with every
other hook, permission, and setting they have configured. This module reads it,
adds exactly one entry, and writes back everything else untouched. On JSON it cannot
parse it **refuses and writes nothing** — silently rewriting a file whose contents we
could not understand is how a tool destroys hand-tuned configuration.

Only ``SessionStart`` is wired. The gate half is staged and unwired (see
:mod:`mgf.fed.sessionhooks._gate`); installing it would create a state that is only
safe if a later step also happens, which **SHP-11** forbids.

**Attribution.** Adapted with attribution from the Sooth family's
``sooth-standard/shp/adopt/``. A **copy, not a dependency** (Sooth's XF-03). The
divergence is deliberate: Sooth copied four shell shims into every repo and resolved
a sibling checkout at run time, because its kit was a directory. mgf's kit is an
installed package, so the shim collapses to one line and there is nothing to keep in
sync.
"""

from __future__ import annotations

import json
import os
import tempfile
from pathlib import Path
from typing import Any

SETTINGS_REL = ".claude/settings.json"

#: The entrypoint the shim reaches. Also the marker used to recognise our own entry
#: on re-install and uninstall, so a hand-edited timeout or wrapper is still found.
HOOK_ENTRYPOINT = "mgf-fed hooks session-start"

#: The one-line staleness notice the shim prints when the entrypoint is present but
#: cannot serve the verb — an ``mgf-fed`` too old to know ``hooks``, or one whose
#: install is broken. Without it that repo's sessions lose their re-teach in complete
#: silence, which is the invisible-absence failure the kit exists to end. ONE line,
#: on stderr, no retry and no loop: a notice that repeats is a notice people learn to
#: scroll past, and this one has to still be legible on the day it matters.
#:
#: Deliberately free of backticks, ``$``, and double quotes: it is embedded in a
#: single-quoted shell word inside a JSON string, and a notice that only survives one
#: of those two layers is a notice that breaks the shim it was meant to explain.
HOOK_STALE_NOTICE = (
    "mgf-fed hooks: session-start unavailable (mgf-fed too old or broken here) — "
    "upgrade mgf-fed, then run: mgf-fed hooks status"
)

#: The exact shim text ``install`` writes. Changing this string upgrades every repo
#: on its next ``install`` (the marker still matches, so the old entry is replaced,
#: never duplicated).
#:
#: Every clause is load-bearing:
#:   ``command -v … &&``  a repo whose environment has no mgf-fed starts normally,
#:                        silently — absence is the operator's choice, not a fault.
#:   ``|| echo … >&2``    but a *present* mgf-fed that cannot serve the verb is a
#:                        fault, and says so once (``HOOK_STALE_NOTICE``).
#:   ``; exit 0``         nothing above this can make a session unstartable.
HOOK_COMMAND = (
    f"command -v mgf-fed >/dev/null 2>&1 && {{ {HOOK_ENTRYPOINT} || "
    f"echo '{HOOK_STALE_NOTICE}' >&2; }}; exit 0"
)

HOOK_EVENT = "SessionStart"
HOOK_TIMEOUT = 20


class SettingsRefused(Exception):
    """The settings file could not be safely merged; nothing was written."""


def settings_path(repo: Path) -> Path:
    """``<repo>/.claude/settings.json``."""
    return repo / ".claude" / "settings.json"


def _load(path: Path) -> dict[str, Any]:
    """Read the settings document. Missing → ``{}``. Unparseable → refuse."""
    if not path.is_file():
        return {}
    try:
        raw = path.read_text(encoding="utf-8")
    except OSError as exc:
        msg = f"cannot read {path} ({type(exc).__name__}: {exc}) — nothing was written."
        raise SettingsRefused(msg) from exc
    if not raw.strip():
        return {}
    try:
        parsed = json.loads(raw)
    except json.JSONDecodeError as exc:
        msg = (
            f"{path} is not valid JSON (line {exc.lineno}, column {exc.colno}: {exc.msg}). "
            f"Refusing to touch it — fix the file by hand, then re-run. Nothing was written."
        )
        raise SettingsRefused(msg) from exc
    if not isinstance(parsed, dict):
        msg = (
            f"{path} holds a {type(parsed).__name__}, not a JSON object. Refusing to touch it "
            f"— nothing was written."
        )
        raise SettingsRefused(msg)
    return parsed


def _session_start_groups(settings: dict[str, Any]) -> list[Any]:
    """The ``hooks.SessionStart`` list, validating the shape on the way in."""
    hooks = settings.get("hooks", {})
    if not isinstance(hooks, dict):
        msg = (
            f"the settings file's `hooks` key holds a {type(hooks).__name__}, not an object. "
            f"Refusing to touch it — nothing was written."
        )
        raise SettingsRefused(msg)
    groups = hooks.get(HOOK_EVENT, [])
    if not isinstance(groups, list):
        msg = (
            f"the settings file's `hooks.{HOOK_EVENT}` key holds a {type(groups).__name__}, "
            f"not a list. Refusing to touch it — nothing was written."
        )
        raise SettingsRefused(msg)
    return groups


def _is_ours(entry: Any) -> bool:
    """Does this hook entry reach our entrypoint (however it was written)?"""
    return isinstance(entry, dict) and HOOK_ENTRYPOINT in str(entry.get("command", ""))


def _our_entries(groups: list[Any]) -> list[dict[str, Any]]:
    found: list[dict[str, Any]] = []
    for group in groups:
        if not isinstance(group, dict):
            continue
        entries = group.get("hooks")
        if not isinstance(entries, list):
            continue
        found += [e for e in entries if _is_ours(e)]
    return found


def _write(path: Path, settings: dict[str, Any]) -> None:
    """Atomically write the merged document, two-space indent + trailing newline.

    Atomic because the target is the user's ``settings.json``: a crash between
    truncate and write would leave the harness reading a half-file, which is a worse
    outcome than anything this command was asked to do.

    Prefers ``mgf.common.io.atomic_write`` — the federation's CF-04 seam, and the
    right thing to use when it is there. Falls back to the same
    write-temp-fsync-replace sequence from the stdlib when it is not, because this
    verb group must work on a bare ``pip install mgf-fed`` (see
    :mod:`mgf.fed.sessionhooks._cli`). The fallback is not a fork of a house seam so
    much as the floor beneath it.
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    text = json.dumps(settings, indent=2, sort_keys=False) + "\n"
    try:
        from mgf.common.io import atomic_write
    except (ModuleNotFoundError, ImportError):
        _atomic_write_stdlib(path, text)
        return
    atomic_write(path, text, mode=0o644)


def _atomic_write_stdlib(path: Path, text: str) -> None:
    """CF-04's sequence with no dependencies: temp in the same dir, fsync, replace."""
    tmp_fd, tmp_name = tempfile.mkstemp(
        dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
    )
    tmp = Path(tmp_name)
    try:
        with os.fdopen(tmp_fd, "w", encoding="utf-8") as handle:
            handle.write(text)
            handle.flush()
            os.fsync(handle.fileno())
        tmp.chmod(0o644)
        tmp.replace(path)
    except BaseException:
        tmp.unlink(missing_ok=True)
        raise


def install(repo: Path) -> tuple[bool, str]:
    """Wire the SessionStart shim into ``repo``. Returns ``(changed, message)``.

    Idempotent: running it twice writes once. An entry that reaches our entrypoint but
    whose text has drifted (an older shim, a hand-edited timeout) is **replaced in
    place**, so a repo never accumulates two of ours.

    Raises :class:`SettingsRefused` when the file cannot be safely merged; in that
    case nothing has been written.
    """
    path = settings_path(repo)
    settings = _load(path)
    groups = _session_start_groups(settings)

    existing = _our_entries(groups)
    if existing and all(e.get("command") == HOOK_COMMAND for e in existing):
        return False, f"already installed in {path} (SessionStart → `{HOOK_ENTRYPOINT}`)."

    if existing:
        for entry in existing:
            entry["command"] = HOOK_COMMAND
            entry["type"] = "command"
            entry.setdefault("timeout", HOOK_TIMEOUT)
        verb = "updated the shim in"
    else:
        groups.append(
            {
                "hooks": [
                    {"type": "command", "command": HOOK_COMMAND, "timeout": HOOK_TIMEOUT},
                ]
            }
        )
        verb = "installed the SessionStart shim into"

    hooks = settings.setdefault("hooks", {})
    hooks[HOOK_EVENT] = groups
    _write(path, settings)
    return True, (
        f"{verb} {path}. Observe mode: it prints the SEAT-02 re-teach and records to "
        f".claude/state/agent-events.jsonl. It never blocks."
    )


def uninstall(repo: Path) -> tuple[bool, str]:
    """Remove our entry (and only ours). Returns ``(changed, message)``.

    Empty containers we emptied are pruned, so an uninstall leaves no ``"hooks": {}``
    residue behind — but a container that held someone else's entry is left alone.
    """
    path = settings_path(repo)
    settings = _load(path)
    groups = _session_start_groups(settings)
    if not _our_entries(groups):
        return False, f"not installed in {path} — nothing to remove."

    pruned: list[Any] = []
    for group in groups:
        if not isinstance(group, dict) or not isinstance(group.get("hooks"), list):
            pruned.append(group)
            continue
        kept = [e for e in group["hooks"] if not _is_ours(e)]
        if kept:
            group["hooks"] = kept
            pruned.append(group)
        elif len(group) > 1:
            # The group carried other keys (a matcher, say) — keep it, emptied, rather
            # than deleting configuration we did not create.
            group["hooks"] = kept
            pruned.append(group)

    hooks = settings.get("hooks")
    if isinstance(hooks, dict):
        if pruned:
            hooks[HOOK_EVENT] = pruned
        else:
            hooks.pop(HOOK_EVENT, None)
        if not hooks:
            settings.pop("hooks", None)
    _write(path, settings)
    return True, f"removed the SessionStart shim from {path}."


def status(repo: Path) -> dict[str, Any]:
    """Describe the installation, without changing anything.

    Never raises: an unparseable settings file is *reported* here rather than refused,
    because a status command that cannot answer "is it on?" is worse than useless.
    """
    path = settings_path(repo)
    report: dict[str, Any] = {
        "repo": str(repo),
        "settings": str(path),
        "settings_exists": path.is_file(),
        "installed": False,
        "entries": 0,
        "current": False,
        "problem": None,
    }
    try:
        settings = _load(path)
        groups = _session_start_groups(settings)
    except SettingsRefused as exc:
        report["problem"] = str(exc)
        return report
    ours = _our_entries(groups)
    report["installed"] = bool(ours)
    report["entries"] = len(ours)
    report["current"] = bool(ours) and all(e.get("command") == HOOK_COMMAND for e in ours)
    return report


__all__ = [
    "HOOK_COMMAND",
    "HOOK_ENTRYPOINT",
    "HOOK_EVENT",
    "HOOK_STALE_NOTICE",
    "HOOK_TIMEOUT",
    "SETTINGS_REL",
    "SettingsRefused",
    "install",
    "settings_path",
    "status",
    "uninstall",
]
