"""Onboarding helper that wires mureo into Claude Desktop.

The Desktop chat host has no ``Read`` / ``Write`` tools, so the
``mureo_*`` MCP surface added in PR #74 is the only way for users to
read or update STRATEGY.md / STATE.json from the chat tab. Getting
that wired up by hand is the single biggest barrier to non-engineer
adoption — three steps are needed (workspace dir, wrapper script,
edit ``claude_desktop_config.json``), and each has its own footgun.

This module exposes ``install_desktop`` so ``mureo install-desktop``
(and any future GUI installer) can perform all three atomically.

Why a wrapper script? Claude Desktop's MCP runtime ignores the
``cwd`` field in its config — the spawned MCP process inherits
``cwd=/`` instead. ``/`` is read-only on macOS, which causes
``mureo_strategy_set`` to fail with ``[Errno 30] Read-only file
system`` while writing the temp file for atomic rename. The wrapper
sidesteps the bug by ``cd``-ing into the workspace before exec'ing
the Python interpreter.
"""

from __future__ import annotations

import contextlib
import json
import os
import shlex
import shutil
import sys
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


class DesktopInstallError(Exception):
    """Base class for install-desktop failures."""


class DesktopInstallUnsupportedPlatformError(DesktopInstallError):
    """Raised when running on a non-macOS host (Phase 1 scope)."""


class DesktopInstallExistsError(DesktopInstallError):
    """Raised when ``mureo`` is already configured and ``--force`` was not given."""


class DesktopConfigCorruptError(DesktopInstallError):
    """Raised when ``claude_desktop_config.json`` is not valid JSON.

    We refuse to overwrite a corrupt config because the user may have
    hand-edited it and we don't want to silently destroy work.
    """


@dataclass(frozen=True)
class InstallResult:
    """Summary of what ``install_desktop`` did (or would do, in dry-run)."""

    workspace: Path
    wrapper_path: Path
    config_path: Path
    backup_path: Path | None
    dry_run: bool


def _macos_config_path() -> Path:
    return (
        Path.home()
        / "Library"
        / "Application Support"
        / "Claude"
        / "claude_desktop_config.json"
    )


def _wrapper_path() -> Path:
    return Path.home() / ".local" / "bin" / "mureo-mcp-wrapper.sh"


def _wrapper_body(workspace: Path, python_executable: str) -> str:
    """Generate the wrapper shell script body.

    ``exec`` replaces the shell with the Python process so signals
    propagate cleanly back to Claude Desktop. Both interpolations go
    through ``shlex.quote`` so paths containing spaces, ``$``, ``;``,
    or other shell metacharacters cannot break out of the command —
    a non-engineer's HOME may well include any of those.

    ``MUREO_BYOD_DIR`` is exported so each workspace's MCP server
    reads/writes BYOD data inside that workspace (``<workspace>/byod/``)
    rather than the global ``~/.mureo/byod/``. This lets demo and real
    workspaces coexist without ``rm -rf`` between them.
    """
    quoted_workspace = shlex.quote(str(workspace))
    quoted_byod_dir = shlex.quote(str(workspace / "byod"))
    quoted_python = shlex.quote(python_executable)
    return (
        "#!/bin/bash\n"
        "# mureo MCP wrapper (auto-generated by `mureo install-desktop`).\n"
        "#\n"
        "# Forces cwd to the mureo workspace because Claude Desktop\n"
        "# does not honour the `cwd` field in its MCP config — without\n"
        "# this, the MCP process inherits cwd=/ which is read-only on\n"
        "# macOS and breaks atomic writes inside `mureo_strategy_set`.\n"
        f"cd {quoted_workspace}\n"
        "# Workspace-local BYOD so demo and real setups can coexist.\n"
        f"export MUREO_BYOD_DIR={quoted_byod_dir}\n"
        f'exec {quoted_python} -m mureo.mcp "$@"\n'
    )


def _load_config(path: Path) -> dict[str, Any]:
    """Read the Claude Desktop config, returning ``{}`` when absent.

    Raises:
        DesktopConfigCorruptError: when the file is not valid JSON or
            its top-level value is not an object. Refusing on a
            non-object payload is what stops a downstream
            ``existing.get(...)`` from raising ``AttributeError`` and
            leaking a stack trace to a non-engineer.
    """
    if not path.exists():
        return {}
    try:
        parsed: Any = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise DesktopConfigCorruptError(
            f"Existing config at {path} is not valid JSON: {exc}. "
            "Fix or remove it manually before re-running install-desktop."
        ) from exc
    if not isinstance(parsed, dict):
        raise DesktopConfigCorruptError(
            f"Existing config at {path} parses to {type(parsed).__name__}, "
            "not an object. Claude Desktop expects a JSON object at the top "
            "level. Fix or remove it before re-running install-desktop."
        )
    return parsed


def _atomic_write_config(path: Path, data: dict[str, Any]) -> None:
    """Write the config atomically: tempfile in same dir + ``os.replace``.

    Why this matters: ``Path.write_text`` is not atomic — a power loss
    or disk-full mid-write would leave a truncated config and Claude
    Desktop loses every MCP entry. Writing to a sibling tempfile and
    then ``os.replace``-ing makes the swap atomic on the same
    filesystem.

    Symlink-following is also blocked: if the user's config is a
    symlink (Dropbox / iCloud sync setups commonly do this), the
    tempfile is created next to the symlink itself, ``os.replace``
    overwrites the symlink, and the original target is not modified.
    Refusing the install if the config is a symlink — for now —
    surfaces that constraint loudly instead of silently doing the
    surprising thing.
    """
    if path.is_symlink():
        raise DesktopConfigCorruptError(
            f"Existing config at {path} is a symlink. install-desktop "
            "refuses to follow it. Resolve the symlink and re-run."
        )
    path.parent.mkdir(parents=True, exist_ok=True)
    payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
    fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".tmp.", dir=str(path.parent))
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as fh:
            fh.write(payload)
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(tmp_name, path)
    except Exception:
        with contextlib.suppress(OSError):
            os.unlink(tmp_name)
        raise


def _backup_config(path: Path) -> Path:
    """Snapshot the existing config to ``<name>.bak.<UTC-timestamp>``.

    UTC keeps timestamps stable across DST shifts. Microsecond
    precision avoids backup collisions on rapid retries (CI loops,
    interactive ``--force`` re-runs); falling back to a numeric
    suffix if even that collides keeps the operation deterministic.
    """
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
    backup = path.with_name(f"{path.name}.bak.{timestamp}")
    counter = 1
    while backup.exists():
        backup = path.with_name(f"{path.name}.bak.{timestamp}-{counter}")
        counter += 1
    shutil.copy2(path, backup)
    return backup


def _validate_demo_scenario(scenario: str) -> None:
    """Reject unknown demo names *before* any filesystem mutation.

    Without this guard a typo (``--with-demo seasonality_trip``) only
    surfaces inside ``materialize`` after the workspace has been
    created and the config rewritten — leaving the user with a
    half-populated state and a confusing error.
    """
    from mureo.demo.scenarios import get_scenario

    try:
        get_scenario(scenario)
    except ValueError as exc:
        raise DesktopInstallError(f"Unknown demo scenario {scenario!r}: {exc}") from exc


def _run_demo_init(workspace: Path, scenario: str) -> None:
    """Seed the workspace with a demo scenario.

    Wrapped in a private helper so tests can ``monkeypatch`` it without
    needing the demo bundle data on disk. Forces overwrite because the
    workspace was just created (or the user explicitly re-installed).
    """
    from mureo.demo.installer import materialize

    materialize(target_dir=workspace, force=True, scenario_name=scenario)


def install_desktop(
    workspace: Path | None = None,
    *,
    with_demo: str | None = None,
    force: bool = False,
    dry_run: bool = False,
) -> InstallResult:
    """Wire mureo into Claude Desktop (macOS).

    Args:
        workspace: Directory to use as the MCP server's cwd. Defaults
            to ``~/mureo``.
        with_demo: Name of a demo scenario to seed the workspace with
            (``seasonality-trap``, ``halo-effect``, ``hidden-champion``,
            ``strategy-drift``). ``None`` skips demo seeding.
        force: When ``True``, replace any existing ``mureo`` entry
            without prompting. When ``False`` and an entry already
            exists, raises ``DesktopInstallExistsError``.
        dry_run: When ``True``, return what would be done without
            mutating the filesystem or config.

    Raises:
        DesktopInstallUnsupportedPlatformError: when not on macOS.
        DesktopInstallExistsError: when an existing ``mureo`` entry
            would be overwritten without ``force``.
        DesktopConfigCorruptError: when the existing config is not
            valid JSON.
    """
    if sys.platform != "darwin":
        raise DesktopInstallUnsupportedPlatformError(
            f"install-desktop currently only supports macOS; "
            f"detected sys.platform={sys.platform!r}. Linux and Windows "
            "support is tracked as a follow-up."
        )

    if with_demo is not None:
        _validate_demo_scenario(with_demo)

    workspace = (workspace or (Path.home() / "mureo")).resolve()
    wrapper = _wrapper_path()
    config_path = _macos_config_path()

    existing = _load_config(config_path)
    servers_raw = existing.get("mcpServers") or {}
    if not isinstance(servers_raw, dict):
        raise DesktopConfigCorruptError(
            f"'mcpServers' in {config_path} is not an object. "
            "Fix or remove it before re-running install-desktop."
        )
    has_existing_mureo = "mureo" in servers_raw
    if has_existing_mureo and not force:
        raise DesktopInstallExistsError(
            f"Claude Desktop already has a 'mureo' MCP entry at "
            f"{config_path}. Re-run with --force to overwrite "
            "(a timestamped backup will be saved)."
        )

    if dry_run:
        return InstallResult(
            workspace=workspace,
            wrapper_path=wrapper,
            config_path=config_path,
            backup_path=None,
            dry_run=True,
        )

    workspace.mkdir(parents=True, exist_ok=True)

    wrapper.parent.mkdir(parents=True, exist_ok=True)
    wrapper.write_text(_wrapper_body(workspace, sys.executable), encoding="utf-8")
    # Explicit 0o755 — don't inherit looser bits from a prior wrapper
    # that may have been written world-writable.
    wrapper.chmod(0o755)

    # Seed the demo BEFORE rewriting the config so a demo-side failure
    # cannot leave the user with a wired-up MCP pointing at an empty
    # workspace. ``_validate_demo_scenario`` already caught typos.
    #
    # The wrapper exports ``MUREO_BYOD_DIR=<workspace>/byod`` for the
    # MCP runtime, but the install-desktop *process itself* is what
    # calls ``materialize`` here — and that lookup also goes through
    # ``byod_data_dir()``. So we temporarily set the same env var in
    # this process for the duration of the seed, then restore the
    # original value (or unset if it was unset) so we don't leak
    # state into anything that imported install_desktop as a library.
    #
    # Not thread-safe: ``os.environ`` is process-global, so two
    # concurrent ``install_desktop`` calls would race the env mutation.
    # Acceptable today because the CLI invokes this once per process;
    # if a future GUI installer parallelises across workspaces we
    # should refactor ``materialize`` to take an explicit ``byod_dir``
    # parameter and drop the env-var dance.
    #
    # ``str(workspace / "byod")`` matches what the wrapper will export
    # (``str(workspace / "byod")`` quoted, no extra ``.resolve()``).
    # ``workspace`` is already resolved above, so the two paths agree.
    if with_demo is not None:
        byod_target = str(workspace / "byod")
        previous_byod = os.environ.get("MUREO_BYOD_DIR")
        os.environ["MUREO_BYOD_DIR"] = byod_target
        try:
            _run_demo_init(workspace=workspace, scenario=with_demo)
        finally:
            if previous_byod is None:
                os.environ.pop("MUREO_BYOD_DIR", None)
            else:
                os.environ["MUREO_BYOD_DIR"] = previous_byod

    backup_path: Path | None = None
    if config_path.exists():
        backup_path = _backup_config(config_path)

    merged = dict(existing)
    servers = dict(servers_raw)
    servers["mureo"] = {"command": str(wrapper)}
    merged["mcpServers"] = servers
    _atomic_write_config(config_path, merged)

    return InstallResult(
        workspace=workspace,
        wrapper_path=wrapper,
        config_path=config_path,
        backup_path=backup_path,
        dry_run=False,
    )


def format_next_steps(result: InstallResult) -> str:
    """Return a human-friendly post-install message for the CLI/GUI to render."""
    backup_line = (
        f"  Config backup: {result.backup_path}\n"
        if result.backup_path is not None
        else ""
    )
    return (
        "Installed.\n"
        f"  Workspace: {result.workspace}\n"
        f"  Wrapper:   {result.wrapper_path}\n"
        f"  Config:    {result.config_path}\n"
        f"{backup_line}"
        "\n"
        "Next steps:\n"
        "  1. Quit Claude Desktop completely (Cmd+Q)\n"
        "  2. Reopen Claude Desktop\n"
        "  3. Open the connector list — you should see 5 mureo_* tools\n"
        '  4. Try in chat: "Call mureo_state_get and show me the result"\n'
    )


__all__ = [
    "DesktopConfigCorruptError",
    "DesktopInstallError",
    "DesktopInstallExistsError",
    "DesktopInstallUnsupportedPlatformError",
    "InstallResult",
    "format_next_steps",
    "install_desktop",
]
