import shutil
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING

import click
from rich import box
from rich.console import Console
from rich.table import Table

from agentmux import __version__

__all__ = ["cli"]

_COMPONENT_SUFFIXES: frozenset[str] = frozenset({".md", ".json", ".toml"})

if TYPE_CHECKING:
    from agentmux.adapters.base import ConvertedFile
    from agentmux.core.component import MCPConfig
    from agentmux.core.setup import Setup
    from agentmux.core.sync import SyncAction

console = Console()
err_console = Console(stderr=True)


@dataclass
class _CliState:
    verbose: bool = False
    quiet: bool = False


pass_state = click.make_pass_decorator(_CliState, ensure=True)


def _load_setup(cwd: Path) -> "Setup":
    from agentmux.core.setup import Setup

    toml_path = cwd / "agentmux.toml"
    if not toml_path.exists():
        raise click.UsageError("agentmux.toml not found. Run 'agentmux init' first.")
    return Setup.from_toml(toml_path)


def _deployment_status(
    converted_files: "list[ConvertedFile]",
    base_dir: Path,
) -> str:
    """Check whether the pre-converted files are deployed under *base_dir*."""
    try:
        files = converted_files

        if not files:
            return "[dim]–[/dim]"

        # A component is fully deployed only when ALL its output files are present
        statuses: list[str] = []
        for converted in files:
            target = base_dir / converted.path
            if not target.exists() and not target.is_symlink():
                statuses.append("missing")
            elif target.is_symlink():
                if converted.source is None:
                    # Generated content (no source) — symlink is unexpected but file exists
                    statuses.append("deployed")
                elif target.resolve() == converted.source.resolve():
                    statuses.append("linked")
                else:
                    statuses.append("stale")
            else:
                statuses.append("deployed")

        if "missing" in statuses:
            return "[red]✗ missing[/red]"
        if "stale" in statuses:
            return "[yellow]⚠ stale[/yellow]"
        if all(s == "linked" for s in statuses):
            return "[green]✓ linked[/green]"
        return "[green]✓ deployed[/green]"
    except Exception as exc:
        err_console.print(f"[dim]Warning: error checking deployment status: {exc}[/dim]")
        return "[yellow]? error[/yellow]"


@click.group()
@click.version_option(__version__, prog_name="agentmux")
@click.option("--verbose", "-v", is_flag=True, default=False, help="Show extra detail.")
@click.option("--quiet", "-q", is_flag=True, default=False, help="Suppress non-essential output.")
@click.pass_context
def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None:
    """agentmux — canonical configuration manager for AI coding agent components."""
    ctx.ensure_object(_CliState)
    state: _CliState = ctx.obj
    state.verbose = verbose
    state.quiet = quiet
    if quiet:
        console.quiet = True


@cli.command()
def doctor() -> None:
    """Check which AI tools are installed and validate configuration paths."""
    from agentmux.adapters.base import AdapterRegistry

    adapters = AdapterRegistry.get_adapters()
    table = Table(title="Tool Detection", box=box.ROUNDED)
    table.add_column("Tool", style="bold")
    table.add_column("Status")
    table.add_column("Config Path")

    for adapter in adapters:
        status = "[green]✓ installed[/green]" if adapter.detect() else "[red]✗ not found[/red]"
        table.add_row(adapter.name, status, str(adapter.get_global_base_dir()))

    console.print(table)


@cli.command("init")
@click.option("--force", is_flag=True, help="Overwrite existing agentmux.toml")
def init(force: bool) -> None:
    """Initialize a new agentmux setup in the current directory."""
    from agentmux.adapters.base import AdapterRegistry

    cwd = Path.cwd()
    toml_path = cwd / "agentmux.toml"

    if toml_path.exists() and not force:
        raise click.UsageError("agentmux.toml already exists. Use --force to overwrite.")

    adapters = AdapterRegistry.get_adapters()
    detected = [a for a in adapters if a.detect()]

    _generate_strategy_tools = {"gemini"}
    tool_lines = "\n".join(
        f'{a.name} = {{ enabled = true, strategy = "{"generate" if a.name in _generate_strategy_tools else "symlink"}" }}'
        for a in detected
    )
    if not tool_lines:
        tool_lines = (
            '# cursor = { enabled = true, strategy = "symlink" }\n'
            '# gemini = { enabled = true, strategy = "generate" }\n'
            '# opencode = { enabled = true, strategy = "symlink" }\n'
            '# claude-code = { enabled = true, strategy = "symlink" }'
        )

    safe_name = (
        cwd.name.replace("\\", "\\\\")
        .replace('"', '\\"')
        .replace("\t", "\\t")
        .replace("\n", "\\n")
        .replace("\r", "\\r")
        .replace("\b", "\\b")
        .replace("\f", "\\f")
    )
    toml_content = f"""\
                        [setup]
                        name = "{safe_name}"
                        version = "0.1.0"
                        description = ""
                        authors = []

                        [tools]
                        {tool_lines}

                        [components]
                        agents = ["agents/*.md"]
                        commands = ["commands/*.md"]
                        skills = ["skills/*/SKILL.md"]
                        rules = ["rules/*.md"]
                        instructions = "instructions.md"
                        # mcps = "mcp.json"                                                        # single shared MCP file
                        # mcps = {{ cursor = "mcp.json", gemini = "gemini-mcp.json" }}              # per-tool MCP files
                        # mcps = {{ cursor = "mcp.json", opencode = "mcp.json", "claude-code" = "mcp.json" }}  # new tools

                    """

    toml_path.write_text(toml_content)
    console.print("[green]✓[/green] Created agentmux.toml")

    for dir_name in ("agents", "commands", "skills", "rules"):
        (cwd / dir_name).mkdir(exist_ok=True)
        console.print(f"[green]✓[/green] Created {dir_name}/")

    instructions_path = cwd / "instructions.md"
    if not instructions_path.exists():
        instructions_path.write_text(
            "# Instructions\n\nAdd your global AI tool instructions here.\n"
        )
        console.print("[green]✓[/green] Created instructions.md")

    console.print(f"\n[bold]Setup initialized in {cwd}[/bold]")
    console.print("Run [bold]agentmux sync[/bold] to deploy to your AI tools.")


@cli.command("new")
@click.argument("component_type")
@click.argument("name")
@click.option("--description", "-d", default="", help="Short description for the component.")
@click.option("--force", is_flag=True, help="Overwrite an existing file without prompting.")
def new(component_type: str, name: str, description: str, force: bool) -> None:
    """Scaffold a new component from a built-in template.

    COMPONENT_TYPE must be one of: agent, command, skill, rule, instructions, mcp.

    \b
    Examples:
      agentmux new agent my-agent
      agentmux new skill my-skill
      agentmux new mcp my-project
    """
    from agentmux.core.component import ComponentType
    from agentmux.core.templates import TEMPLATES, render_template

    # ── 1. Validate component type ───────────────────────────────────────────
    try:
        comp_type = ComponentType(component_type.lower())
    except ValueError as exc:
        valid = ", ".join(ct.value for ct in ComponentType)
        raise click.UsageError(
            f"Unknown component type {component_type!r}. Valid types are: {valid}"
        ) from exc

    template = TEMPLATES[comp_type]

    # ── 2. Resolve the output directory from agentmux.toml if present ────────
    cwd = Path.cwd()
    toml_path = cwd / "agentmux.toml"
    output_subdir: str = template.default_subdir

    if toml_path.exists():
        from agentmux.core.setup import Setup

        try:
            setup = Setup.from_toml(toml_path)
            cfg = setup.components_config

            if comp_type == ComponentType.AGENT and cfg.agents:
                # Extract the directory from the first glob pattern, e.g. "agents/*.md" → "agents"
                output_subdir = str(Path(cfg.agents[0]).parent)
            elif comp_type == ComponentType.COMMAND and cfg.commands:
                output_subdir = str(Path(cfg.commands[0]).parent)
            elif comp_type == ComponentType.SKILL and cfg.skills:
                # Skill glob is "skills/*/SKILL.md"; parent of that is "skills/*",
                # and parent of that is "skills".
                output_subdir = str(Path(cfg.skills[0]).parent.parent)
            elif comp_type == ComponentType.RULE and cfg.rules:
                output_subdir = str(Path(cfg.rules[0]).parent)
            elif comp_type == ComponentType.INSTRUCTIONS and cfg.instructions:
                # instructions is a direct path like "instructions.md" — use its parent dir.
                output_subdir = str(Path(cfg.instructions).parent)
            elif comp_type == ComponentType.MCP and cfg.mcps:
                # mcps can be a str or dict[str, str].
                if isinstance(cfg.mcps, str):
                    output_subdir = str(Path(cfg.mcps).parent)
                elif isinstance(cfg.mcps, dict) and cfg.mcps:
                    # Use the first configured MCP path.
                    first_path = next(iter(cfg.mcps.values()))
                    output_subdir = str(Path(first_path).parent)
        except Exception as exc:
            err_console.print(
                f"[yellow]Warning: could not read agentmux.toml ({exc}); "
                "using default directory.[/yellow]"
            )

    # ── 3. Determine the full target path ────────────────────────────────────
    base_dir = cwd / output_subdir if output_subdir != "." else cwd
    output_filename = template.output_filename(name)

    if template.creates_directory:
        # Skill: creates <skills>/<name>/SKILL.md
        component_dir = base_dir / output_filename
        target = component_dir / "SKILL.md"
    else:
        component_dir = None
        target = base_dir / output_filename

    # ── 4. Overwrite guard ───────────────────────────────────────────────────
    if target.exists() and not force:
        raise click.UsageError(f"'{target}' already exists. Use --force to overwrite.")

    # ── 5. Render and write ──────────────────────────────────────────────────
    content = render_template(comp_type, name=name, description=description)

    if component_dir is not None:
        component_dir.mkdir(parents=True, exist_ok=True)
    else:
        target.parent.mkdir(parents=True, exist_ok=True)

    target.write_text(content)
    console.print(f"[green]✓[/green] Created [bold]{target.relative_to(cwd)}[/bold]")


@cli.command("sync")
@click.option("--tool", "tool_name", default=None, help="Sync only to this tool")
@click.option(
    "--project",
    is_flag=True,
    help="Also sync project-level components (rules, instructions) into current directory",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview what would be synced without writing any files.",
)
@pass_state
def sync(state: _CliState, tool_name: str | None, project: bool, dry_run: bool) -> None:
    """Sync components to all configured tools."""
    from agentmux.core.sync import sync_setup

    setup = _load_setup(Path.cwd())
    tools = [tool_name] if tool_name else None
    project_dir = Path.cwd() if project else None

    status_msg = "[bold]Previewing sync…" if dry_run else "[bold]Syncing components…"
    with console.status(status_msg):
        actions = sync_setup(setup, tools=tools, project_dir=project_dir, dry_run=dry_run)

    if not actions:
        if not state.quiet:
            console.print("[dim]No components to sync.[/dim]")
        return

    counts = _print_sync_table(actions, dry_run=dry_run, verbose=state.verbose)
    if not dry_run:
        _save_rollback_manifest(actions, setup.name)
    if counts["error"] > 0:
        sys.exit(1)


@cli.command("status")
@click.option(
    "--project",
    is_flag=True,
    help="Also check project-level components (rules, instructions) in the current directory",
)
def status(project: bool) -> None:
    """Show deployment status of all components across configured tools."""
    from agentmux.adapters.base import AdapterRegistry

    setup = _load_setup(Path.cwd())
    components = setup.load_components()
    all_adapters = AdapterRegistry.get_adapters()

    # Only show adapters explicitly enabled in [tools]; if no [tools], show all
    configured_adapters = (
        [a for a in all_adapters if setup.tools.get(a.name) and setup.tools[a.name].enabled]
        if setup.tools
        else all_adapters
    )

    project_dir = Path.cwd() if project else None

    table = Table(title=f"Setup: {setup.name} v{setup.version}", box=box.ROUNDED)
    table.add_column("Component", style="bold")
    table.add_column("Type")
    for adapter in configured_adapters:
        table.add_column(adapter.name.capitalize(), justify="center")

    from agentmux.core.component import ComponentType

    for comp_type, comp_list in components.items():
        if comp_type == ComponentType.MCP:
            # MCP is handled per-adapter via get_mcp_for_tool; skip the shared list
            continue
        for component in comp_list:
            name = getattr(component, "name", "instructions")
            row: list[str] = [name, comp_type.value]
            for adapter in configured_adapters:
                # Determine the correct base dir based on the component's scope
                converted_files = adapter.convert(component, comp_type)
                has_project_scope = any(f.scope == "project" for f in converted_files)
                if has_project_scope and project_dir is not None:
                    base_dir = project_dir / adapter.get_project_subdir()
                elif has_project_scope:
                    # Project-scoped but no --project flag: indicate it requires context
                    row.append("[dim]–project[/dim]")
                    continue
                else:
                    base_dir = adapter.get_global_base_dir()
                row.append(_deployment_status(converted_files, base_dir))
            table.add_row(*row)

    # Show MCP rows using per-tool lookup — mirrors sync_setup's get_mcp_for_tool logic so
    # that per-tool MCP files are correctly attributed to each adapter rather than
    # showing all MCP files for all adapters.
    #
    # When multiple adapters share the same MCP file (mcps = "mcp.json"), they are
    # collapsed into a single "mcp" row showing each adapter's deployment status.
    # When adapters have distinct per-tool files, each gets its own row.
    mcp_by_adapter: dict[str, MCPConfig | None] = {
        a.name: setup.get_mcp_for_tool(a.name) for a in configured_adapters
    }
    # Group adapters by their MCP source path so shared configs produce one row
    seen_mcp_paths: set[str] = set()
    for primary_adapter in configured_adapters:
        primary_mcp = mcp_by_adapter[primary_adapter.name]
        if primary_mcp is None:
            continue
        path_key = str(primary_mcp.source_path) if primary_mcp.source_path else id(primary_mcp)
        if str(path_key) in seen_mcp_paths:
            continue
        seen_mcp_paths.add(str(path_key))

        # Determine which adapters share this exact MCP file
        row_label = "mcp"
        row = [row_label, ComponentType.MCP.value]
        for a in configured_adapters:
            adapter_mcp = mcp_by_adapter[a.name]
            if adapter_mcp is None:
                row.append("[dim]–[/dim]")
            elif str(adapter_mcp.source_path) == str(path_key):
                converted_files = a.convert_mcp(adapter_mcp)
                base_dir = a.get_global_base_dir()
                row.append(_deployment_status(converted_files, base_dir))
            else:
                # This adapter has a different per-tool MCP — it'll get its own row
                row.append("[dim]–[/dim]")
        table.add_row(*row)

    console.print(table)


@cli.command("validate")
def validate() -> None:
    """Validate the current setup's agentmux.toml and all components."""
    from agentmux.adapters.base import AdapterRegistry
    from agentmux.core.setup import Setup

    cwd = Path.cwd()
    toml_path = cwd / "agentmux.toml"
    if not toml_path.exists():
        raise click.UsageError("agentmux.toml not found. Run 'agentmux init' first.")

    has_errors = False

    try:
        setup = Setup.from_toml(toml_path)
        console.print("[green]✓[/green] agentmux.toml")
    except Exception as exc:
        console.print(f"[red]✗[/red] agentmux.toml: {exc}")
        sys.exit(1)

    try:
        components = setup.load_components()
    except Exception as exc:
        console.print(f"[red]✗[/red] Failed to load components: {exc}")
        sys.exit(1)

    all_adapters = AdapterRegistry.get_adapters()

    # Only validate against adapters explicitly enabled in [tools]; if no [tools], validate all
    configured_adapters = (
        [a for a in all_adapters if setup.tools.get(a.name) and setup.tools[a.name].enabled]
        if setup.tools
        else all_adapters
    )

    for comp_type, comp_list in components.items():
        for component in comp_list:
            src = getattr(component, "source_path", getattr(component, "source_dir", None))
            label = f"{comp_type.value}: {getattr(component, 'name', src)}"
            # Note: model_validate here is a no-op if load_components() succeeded;
            # real validation value comes from adapter.validate() below.
            console.print(f"[green]✓[/green] {label}")

            for adapter in configured_adapters:
                for err in adapter.validate(component):
                    console.print(f"[red]✗[/red] [{adapter.name}] {label}: {err}")
                    has_errors = True

    if not has_errors:
        console.print("\n[bold green]All components are valid.[/bold green]")
    else:
        sys.exit(1)


# ---------------------------------------------------------------------------
# Shared helpers for install/update/uninstall/diff
# ---------------------------------------------------------------------------


def _derive_setup_name(source: str) -> str:
    """Derive a short setup name from a git URL or local path.

    Delegates to :func:`agentmux.utils.git.derive_name_from_source`.

    Examples::

        https://github.com/user/my-setup  ->  "my-setup"
        https://github.com/user/my-setup.git  ->  "my-setup"
        /home/user/my-setup  ->  "my-setup"
        my-setup  ->  "my-setup"
    """
    from agentmux.utils.git import derive_name_from_source

    return derive_name_from_source(source)


def _is_git_url(source: str) -> bool:
    """Return True when *source* looks like a git remote URL."""
    from agentmux.utils.git import is_git_url

    return is_git_url(source)


def _save_rollback_manifest(actions: list["SyncAction"], setup_name: str) -> None:
    """Build a :class:`~agentmux.core.rollback.RollbackManifest` from *actions* and persist it.

    Only ``"created"`` and ``"updated"`` actions are recorded — ``"skipped"``
    and ``"error"`` entries carry no rollback-relevant state.
    """
    from agentmux.core.rollback import RollbackEntry, RollbackManifest, save_manifest

    entries: list[RollbackEntry] = []
    for a in actions:
        if a.action == "created" or a.action == "updated":
            entries.append(
                RollbackEntry(
                    target_path=str(a.target),
                    backup_path=str(a.backup_path) if a.backup_path else None,
                    action=a.action,
                )
            )
    if not entries:
        return
    manifest = RollbackManifest(
        timestamp=datetime.now(tz=timezone.utc),
        setup_name=setup_name,
        entries=entries,
    )
    try:
        save_manifest(manifest)
    except (OSError, ValueError) as exc:
        err_console.print(f"[yellow]Warning: could not save rollback manifest: {exc}[/yellow]")


def _print_sync_table(
    actions: list["SyncAction"],
    *,
    dry_run: bool = False,
    verbose: bool = False,
) -> dict[str, int]:
    """Render the standard sync-results Rich table to the console.

    Args:
        actions: The list of sync actions to display.
        dry_run: When ``True``, the table title is annotated with "(dry run)".
        verbose: When ``True``, skipped entries are also shown in the table.

    Returns:
        A counts dict with keys ``"created"``, ``"updated"``, ``"skipped"``,
        and ``"error"``.  Callers are responsible for deciding whether to exit
        based on the error count.
    """
    title = "Sync Results (dry run)" if dry_run else "Sync Results"
    table = Table(title=title, box=box.ROUNDED)
    table.add_column("Status", no_wrap=True)
    table.add_column("Component")
    table.add_column("Type")
    table.add_column("Target", overflow="fold")

    color_map = {"created": "green", "updated": "blue", "skipped": "dim", "error": "red"}
    icon_map = {"created": "✓", "updated": "↑", "skipped": "–", "error": "✗"}

    counts: dict[str, int] = {"created": 0, "updated": 0, "skipped": 0, "error": 0}
    for action in actions:
        counts[action.action] = counts.get(action.action, 0) + 1
        if action.action == "skipped" and not verbose:
            continue  # hide skipped rows unless --verbose
        color = color_map.get(action.action, "white")
        icon = icon_map.get(action.action, "?")
        detail = f" ({action.detail})" if action.detail else ""
        table.add_row(
            f"[{color}]{icon} {action.action}[/{color}]",
            action.component_name,
            action.component_type,
            str(action.target) + detail,
        )

    console.print(table)
    console.print(
        f"\n[bold]Summary:[/bold] "
        f"[green]{counts['created']} created[/green], "
        f"[blue]{counts['updated']} updated[/blue], "
        f"[dim]{counts['skipped']} skipped[/dim], "
        f"[red]{counts['error']} errors[/red]"
    )
    return counts


# ---------------------------------------------------------------------------
# install
# ---------------------------------------------------------------------------


@cli.command("install")
@click.argument("source")
@click.option(
    "--project",
    is_flag=True,
    help="Also deploy project-level components (rules, instructions) into the current directory.",
)
@click.option(
    "--name",
    "setup_name",
    default=None,
    help="Override the derived setup name used as the registry key.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview what would be deployed without writing any files or registering the setup.",
)
def install(source: str, project: bool, setup_name: str | None, dry_run: bool) -> None:
    """Install a setup from a git URL or local path.

    SOURCE can be:

    \b
      - A git remote URL: https://github.com/user/my-setup
      - A local directory path: ./my-setup or /abs/path/to/my-setup
    """
    from agentmux.core.registry import REGISTRY_DIR, Registry, RegistryEntry
    from agentmux.core.setup import Setup
    from agentmux.core.sync import sync_setup
    from agentmux.utils.git import clone_repo, get_head_sha

    name = setup_name or _derive_setup_name(source)
    registry = Registry.load()

    if not dry_run and registry.get(name) is not None:
        raise click.UsageError(
            f"A setup named {name!r} is already installed. "
            "Run 'agentmux update' to update it, or 'agentmux uninstall' to remove it first."
        )

    # ── 1. Obtain the setup directory ────────────────────────────────────────
    target_dir: Path | None = None
    tmp_clone_parent: Path | None = None
    if _is_git_url(source):
        if dry_run:
            # For dry-run, clone to a temp directory and clean up afterwards.
            import tempfile

            tmp_clone_parent = Path(tempfile.mkdtemp())
            target_dir = tmp_clone_parent / name
        else:
            target_dir = REGISTRY_DIR / name
            if target_dir.exists():
                raise click.UsageError(
                    f"Directory '{target_dir}' already exists but is not registered. "
                    "Remove it manually before installing."
                )
            REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
        with console.status(f"[bold]Cloning {source}…"):
            try:
                clone_repo(source, target_dir)
            except RuntimeError as exc:
                raise click.ClickException(f"Failed to clone repository: {exc}") from exc
        setup_dir = target_dir
        sha = get_head_sha(setup_dir)
        source_url = source
    else:
        # Local path
        setup_dir = Path(source).expanduser().resolve()
        if not setup_dir.is_dir():
            raise click.UsageError(
                f"Local path '{setup_dir}' does not exist or is not a directory."
            )
        try:
            sha = get_head_sha(setup_dir)
        except RuntimeError:
            sha = ""  # not a git repo — acceptable for local installs
        source_url = str(setup_dir)

    # ── 2. Validate agentmux.toml ────────────────────────────────────────────
    toml_path = setup_dir / "agentmux.toml"
    cloned_dir: Path | None = target_dir

    def _cleanup_cloned() -> None:
        # Clean up the parent temp directory (not just the subdirectory) to
        # avoid leaving an empty directory behind on dry-run invocations.
        root = tmp_clone_parent if tmp_clone_parent is not None else cloned_dir
        if root is not None and root.exists():
            shutil.rmtree(root)

    if not toml_path.exists():
        _cleanup_cloned()
        raise click.ClickException(
            f"No agentmux.toml found in '{setup_dir}'. "
            "The source does not appear to be a valid agentmux setup."
        )

    try:
        setup = Setup.from_toml(toml_path)
    except Exception as exc:
        _cleanup_cloned()
        raise click.ClickException(f"Failed to load agentmux.toml: {exc}") from exc

    # ── 3. Sync (deploy) components ──────────────────────────────────────────
    project_dir = Path.cwd() if project else None
    status_msg = "[bold]Previewing deployment…" if dry_run else "[bold]Deploying components…"
    try:
        with console.status(status_msg):
            actions = sync_setup(setup, project_dir=project_dir, dry_run=dry_run)
    except Exception as exc:
        _cleanup_cloned()
        raise click.ClickException(f"Failed to deploy components: {exc}") from exc

    if dry_run:
        # Clean up the temp clone and skip registration.
        _cleanup_cloned()
        console.print(f"[dim](dry run) Would install [bold]{name}[/bold] from {source_url}[/dim]")
        if sha:
            console.print(f"  SHA: [dim]{sha[:12]}[/dim]")
        _print_sync_table(actions, dry_run=True)
        return

    # ── 4. Register the installed setup ─────────────────────────────────────
    entry = RegistryEntry(
        name=name,
        source_url=source_url,
        installed_sha=sha,
        installed_at=datetime.now(tz=timezone.utc),
        local_path=setup_dir,
    )
    registry.add(entry)

    console.print(f"[green]✓[/green] Installed [bold]{name}[/bold] from {source_url}")
    if sha:
        console.print(f"  SHA: [dim]{sha[:12]}[/dim]")
    counts = _print_sync_table(actions)
    _save_rollback_manifest(actions, setup.name)
    if counts["error"] > 0:
        sys.exit(1)


# ---------------------------------------------------------------------------
# update
# ---------------------------------------------------------------------------


@cli.command("update")
@click.argument("setup_name", required=False, default=None)
@click.option(
    "--project",
    is_flag=True,
    help="Also deploy project-level components (rules, instructions) into the current directory.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview what would be redeployed without writing any files.",
)
def update(setup_name: str | None, project: bool, dry_run: bool) -> None:
    """Update an installed setup to the latest commit.

    SETUP_NAME defaults to the only installed setup when there is exactly one.
    """
    from agentmux.core.registry import Registry
    from agentmux.core.setup import Setup
    from agentmux.core.sync import sync_setup
    from agentmux.utils.git import diff_summary, pull_repo

    registry = Registry.load()

    # Resolve which setup to update
    if setup_name is None:
        all_entries = registry.list_all()
        if len(all_entries) == 0:
            raise click.UsageError("No setups are installed. Run 'agentmux install' first.")
        if len(all_entries) > 1:
            names = ", ".join(e.name for e in all_entries)
            raise click.UsageError(
                f"Multiple setups are installed ({names}). "
                "Specify which one to update: agentmux update <setup-name>"
            )
        resolved_name = all_entries[0].name
    else:
        resolved_name = setup_name

    entry = registry.get(resolved_name)
    if entry is None:
        raise click.UsageError(
            f"No installed setup named {resolved_name!r}. "
            "Run 'agentmux install' first or check 'agentmux list'."
        )

    if not entry.local_path.is_dir():
        raise click.ClickException(
            f"Setup directory '{entry.local_path}' does not exist. "
            "The registry may be stale — try uninstalling and reinstalling."
        )

    # ── 1. Pull latest changes ───────────────────────────────────────────────
    old_sha = entry.installed_sha
    with console.status(f"[bold]Pulling latest changes for {resolved_name!r}…"):
        try:
            new_sha = pull_repo(entry.local_path)
        except RuntimeError as exc:
            raise click.ClickException(f"Failed to pull updates: {exc}") from exc

    # ── 2. Show diff summary ─────────────────────────────────────────────────
    if old_sha and new_sha and old_sha != new_sha:
        try:
            diff_stat = diff_summary(entry.local_path, old_sha, new_sha)
            if diff_stat:
                console.print(f"\n[bold]Changes ({old_sha[:8]}..{new_sha[:8]}):[/bold]")
                console.print(diff_stat)
        except RuntimeError:
            pass  # diff is best-effort; don't fail the update over it
    elif old_sha == new_sha:
        console.print(f"[dim]Already up to date ({new_sha[:12] if new_sha else 'unknown'})[/dim]")

    # ── 3. Re-sync components ────────────────────────────────────────────────
    toml_path = entry.local_path / "agentmux.toml"
    if not toml_path.exists():
        raise click.ClickException(
            f"No agentmux.toml found in '{entry.local_path}'. The setup appears to be broken."
        )

    try:
        setup = Setup.from_toml(toml_path)
    except Exception as exc:
        raise click.ClickException(f"Failed to load agentmux.toml: {exc}") from exc

    project_dir = Path.cwd() if project else None
    status_msg = "[bold]Previewing redeploy…" if dry_run else "[bold]Redeploying components…"
    with console.status(status_msg):
        actions = sync_setup(setup, project_dir=project_dir, dry_run=dry_run)

    if dry_run:
        console.print(
            f"[dim](dry run) Would update [bold]{resolved_name}[/bold] to "
            f"{new_sha[:12] if new_sha else 'latest'}[/dim]"
        )
        _print_sync_table(actions, dry_run=True)
        return

    # ── 4. Update registry entry ─────────────────────────────────────────────
    from dataclasses import replace

    updated_entry = replace(
        entry,
        installed_sha=new_sha,
        installed_at=datetime.now(tz=timezone.utc),
    )
    registry.add(updated_entry)

    console.print(f"[green]✓[/green] Updated [bold]{resolved_name}[/bold]")
    if new_sha:
        console.print(f"  SHA: [dim]{new_sha[:12]}[/dim]")
    counts = _print_sync_table(actions)
    _save_rollback_manifest(actions, setup.name)
    if counts["error"] > 0:
        sys.exit(1)


# ---------------------------------------------------------------------------
# uninstall
# ---------------------------------------------------------------------------


@cli.command("uninstall")
@click.argument("setup_name")
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
@click.option(
    "--project",
    is_flag=True,
    help="Also remove project-level files (rules, instructions, MCP) from the current directory.",
)
def uninstall(setup_name: str, yes: bool, project: bool) -> None:
    """Remove an installed setup and its deployed files."""
    from agentmux.core.registry import Registry
    from agentmux.core.setup import Setup

    registry = Registry.load()
    entry = registry.get(setup_name)
    if entry is None:
        raise click.UsageError(
            f"No installed setup named {setup_name!r}. Check installed setups with 'agentmux list'."
        )

    if not yes:
        click.confirm(
            f"Uninstall setup {setup_name!r} and remove all deployed files?",
            abort=True,
        )

    # ── 1. Load setup to enumerate deployed files ────────────────────────────
    toml_path = entry.local_path / "agentmux.toml"
    setup: Setup | None = None
    if toml_path.exists():
        try:
            setup = Setup.from_toml(toml_path)
        except Exception as exc:
            err_console.print(
                f"[yellow]Warning: could not load agentmux.toml ({exc}); "
                "deployed files will not be removed.[/yellow]"
            )

    # ── 2. Remove deployed files/symlinks ────────────────────────────────────
    removed_count = 0
    if setup is not None:
        from agentmux.adapters.base import AdapterRegistry
        from agentmux.core.component import ComponentType

        all_adapters = AdapterRegistry.get_adapters()
        configured_adapters = (
            [a for a in all_adapters if setup.tools.get(a.name) and setup.tools[a.name].enabled]
            if setup.tools
            else all_adapters
        )

        components = setup.load_components()
        regular_types = (
            ComponentType.AGENT,
            ComponentType.COMMAND,
            ComponentType.SKILL,
            ComponentType.RULE,
            ComponentType.INSTRUCTIONS,
        )

        project_dir = Path.cwd() if project else None

        for adapter in configured_adapters:
            global_base = adapter.get_global_base_dir()

            for comp_type in regular_types:
                for component in components.get(comp_type, []):
                    for converted in adapter.convert(component, comp_type):
                        if converted.scope == "global":
                            base: Path | None = global_base
                        elif project_dir is not None:
                            base = project_dir / adapter.get_project_subdir()
                        else:
                            base = None
                        if base is None:
                            continue
                        target = base / converted.path
                        if target.exists() or target.is_symlink():
                            try:
                                if target.is_symlink() or target.is_file():
                                    target.unlink()
                                elif target.is_dir():
                                    shutil.rmtree(target)
                                removed_count += 1
                                console.print(f"  [dim]removed {target}[/dim]")
                            except OSError as exc:
                                err_console.print(
                                    f"[yellow]Warning: could not remove '{target}': {exc}[/yellow]"
                                )

            mcp = setup.get_mcp_for_tool(adapter.name)
            if mcp is not None:
                for converted in adapter.convert_mcp(mcp):
                    if converted.scope == "global":
                        mcp_base: Path | None = global_base
                    elif project_dir is not None:
                        mcp_base = project_dir / adapter.get_project_subdir()
                    else:
                        mcp_base = None
                    if mcp_base is None:
                        continue
                    target = mcp_base / converted.path
                    if target.exists() or target.is_symlink():
                        if converted.merge:
                            # Merged files may contain entries from sources other than this
                            # setup (e.g. user's own MCP servers).  Deleting the entire file
                            # would cause data loss.  Warn the user and skip.
                            err_console.print(
                                f"[yellow]Warning: '{target}' was deployed via merge and may "
                                "contain entries from other sources. Skipping automatic removal "
                                "— remove agentmux-managed servers manually if needed.[/yellow]"
                            )
                            continue
                        try:
                            if target.is_symlink() or target.is_file():
                                target.unlink()
                            elif target.is_dir():
                                shutil.rmtree(target)
                            removed_count += 1
                            console.print(f"  [dim]removed {target}[/dim]")
                        except OSError as exc:
                            err_console.print(
                                f"[yellow]Warning: could not remove '{target}': {exc}[/yellow]"
                            )

    # ── 3. Remove the cloned setup directory (git installs only) ─────────────
    from agentmux.core.registry import REGISTRY_DIR

    if entry.local_path.is_relative_to(REGISTRY_DIR) and entry.local_path.is_dir():
        try:
            shutil.rmtree(entry.local_path)
            console.print(f"  [dim]removed {entry.local_path}[/dim]")
        except OSError as exc:
            err_console.print(
                f"[yellow]Warning: could not remove setup directory "
                f"'{entry.local_path}': {exc}[/yellow]"
            )

    # ── 4. Remove registry entry ─────────────────────────────────────────────
    registry.remove(setup_name)

    console.print(
        f"[green]✓[/green] Uninstalled [bold]{setup_name}[/bold] ({removed_count} file(s) removed)"
    )


# ---------------------------------------------------------------------------
# diff
# ---------------------------------------------------------------------------


@cli.command("diff")
@click.argument("setup_name", required=False, default=None)
@click.option(
    "--fetch/--no-fetch",
    default=True,
    help="Fetch remote changes before comparing (default: --fetch).",
)
def diff(setup_name: str | None, fetch: bool) -> None:
    """Show what has changed in a setup since it was last installed or updated.

    SETUP_NAME defaults to the only installed setup when there is exactly one.
    """
    from agentmux.core.registry import Registry
    from agentmux.utils.git import (
        diff_summary,
        fetch_repo,
        get_head_sha,
        get_remote_tracking_sha,
    )

    registry = Registry.load()

    # Resolve which setup to diff
    if setup_name is None:
        all_entries = registry.list_all()
        if len(all_entries) == 0:
            raise click.UsageError("No setups are installed.")
        if len(all_entries) > 1:
            names = ", ".join(e.name for e in all_entries)
            raise click.UsageError(
                f"Multiple setups are installed ({names}). "
                "Specify which one to diff: agentmux diff <setup-name>"
            )
        resolved_name = all_entries[0].name
    else:
        resolved_name = setup_name

    entry = registry.get(resolved_name)
    if entry is None:
        raise click.UsageError(f"No installed setup named {resolved_name!r}.")

    if not entry.local_path.is_dir():
        raise click.ClickException(
            f"Setup directory '{entry.local_path}' does not exist. The registry may be stale."
        )

    # ── 1. Optionally fetch to get remote changes without applying them ───────
    if fetch:
        with console.status("[bold]Fetching remote changes…"):
            try:
                fetch_repo(entry.local_path)
            except RuntimeError as exc:
                err_console.print(f"[yellow]Warning: git fetch failed: {exc}[/yellow]")

    # ── 2. Determine current HEAD (after fetch, still local HEAD) ─────────────
    try:
        current_sha = get_head_sha(entry.local_path)
    except RuntimeError as exc:
        raise click.ClickException(f"Could not determine current HEAD: {exc}") from exc

    # ── 3. Check if remote has new commits ────────────────────────────────────
    # Try to find the remote tracking branch to compare against
    remote_sha: str | None = None
    if fetch:
        remote_sha = get_remote_tracking_sha(entry.local_path)

    installed_sha = entry.installed_sha

    console.print(f"\n[bold]Setup:[/bold] {resolved_name}")
    console.print(f"[bold]Source:[/bold] {entry.source_url}")
    console.print(
        f"[bold]Installed:[/bold] {installed_sha[:12] if installed_sha else 'unknown'} "
        f"at {entry.installed_at.strftime('%Y-%m-%d %H:%M UTC')}"
    )
    console.print(f"[bold]Current HEAD:[/bold] {current_sha[:12] if current_sha else 'unknown'}")

    # ── 4. Show diff between installed SHA and current HEAD ───────────────────
    if installed_sha and current_sha and installed_sha != current_sha:
        console.print(
            f"\n[bold]Changes since install ({installed_sha[:8]}..{current_sha[:8]}):[/bold]"
        )
        try:
            stat = diff_summary(entry.local_path, installed_sha, current_sha)
            if stat:
                console.print(stat)
            else:
                console.print("[dim]No file changes.[/dim]")
        except RuntimeError as exc:
            err_console.print(f"[yellow]Warning: could not compute diff: {exc}[/yellow]")
    elif installed_sha == current_sha:
        console.print("\n[green]Up to date.[/green] No changes since last install.")
    else:
        console.print("\n[dim]Cannot compute diff — SHA information unavailable.[/dim]")

    # ── 5. Indicate if remote is ahead ────────────────────────────────────────
    if remote_sha and remote_sha != current_sha:
        console.print(
            f"\n[yellow]Remote is ahead of local HEAD ({remote_sha[:12]}).[/yellow] "
            "Run [bold]agentmux update[/bold] to pull the latest changes."
        )


# ---------------------------------------------------------------------------
# list
# ---------------------------------------------------------------------------


@cli.command("list")
def list_installed() -> None:
    """List all installed setups."""
    from agentmux.core.registry import Registry

    registry = Registry.load()
    entries = registry.list_all()

    if not entries:
        console.print("[dim]No setups installed.[/dim]")
        return

    table = Table(title="Installed Setups", box=box.ROUNDED)
    table.add_column("Name", style="bold")
    table.add_column("Source")
    table.add_column("SHA", style="dim")
    table.add_column("Installed At")
    table.add_column("Local Path", overflow="fold")

    for entry in entries:
        table.add_row(
            entry.name,
            entry.source_url,
            entry.installed_sha[:12] if entry.installed_sha else "–",
            entry.installed_at.strftime("%Y-%m-%d %H:%M UTC"),
            str(entry.local_path),
        )

    console.print(table)


# ---------------------------------------------------------------------------
# rollback
# ---------------------------------------------------------------------------


@cli.command("rollback")
@click.argument("setup_name", required=False, default=None)
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
def rollback(setup_name: str | None, yes: bool) -> None:
    """Restore files to the state before the last sync/install/update.

    SETUP_NAME defaults to the only entry in the rollback history when there is
    exactly one.  Run 'agentmux list' to find the name of an installed setup.
    """
    from agentmux.core.rollback import list_manifests, list_setup_names, load_latest_manifest

    # ── 1. Resolve setup name ────────────────────────────────────────────────
    if setup_name is None:
        all_names = list_setup_names()

        if len(all_names) == 0:
            raise click.UsageError("No rollback history found. Run 'agentmux sync' first.")
        if len(all_names) > 1:
            names = ", ".join(sorted(all_names))
            raise click.UsageError(
                f"Multiple setups have rollback history ({names}). "
                "Specify which one: agentmux rollback <setup-name>"
            )
        setup_name = next(iter(all_names))

    # ── 2. Load the manifest ─────────────────────────────────────────────────
    if not list_manifests(setup_name):
        raise click.UsageError(
            f"No rollback history found for {setup_name!r}. "
            "Run 'agentmux sync' to create a rollback point."
        )

    manifest = load_latest_manifest(setup_name)
    if manifest is None:
        raise click.UsageError(f"Could not load rollback manifest for {setup_name!r}.")

    if not manifest.entries:
        console.print("[dim]Nothing to roll back (no files were created or updated).[/dim]")
        return

    # ── 3. Summarise what will be undone ─────────────────────────────────────
    console.print(
        f"\n[bold]Rolling back[/bold] [cyan]{setup_name}[/cyan] "
        f"(synced at {manifest.timestamp.strftime('%Y-%m-%d %H:%M UTC')})"
    )
    rb_table = Table(title="Files to restore", box=box.ROUNDED)
    rb_table.add_column("Action")
    rb_table.add_column("File", overflow="fold")
    rb_table.add_column("Restore from", overflow="fold")

    for entry in manifest.entries:
        if entry.action == "created":
            rb_table.add_row("[red]delete[/red]", entry.target_path, "–")
        else:
            restore_src = entry.backup_path or "[yellow]backup missing[/yellow]"
            rb_table.add_row("[blue]restore[/blue]", entry.target_path, str(restore_src))

    console.print(rb_table)

    # ── 4. Confirm ───────────────────────────────────────────────────────────
    if not yes:
        click.confirm("\nProceed with rollback?", abort=True)

    # ── 5. Execute ───────────────────────────────────────────────────────────
    errors = 0
    for entry in manifest.entries:
        target = Path(entry.target_path)

        if entry.action == "created":
            try:
                if target.is_symlink() or target.is_file():
                    target.unlink()
                elif target.is_dir():
                    shutil.rmtree(target)
                console.print(f"  [red]deleted[/red] {target}")
            except OSError as exc:
                err_console.print(f"  [red]✗[/red] Could not delete '{target}': {exc}")
                errors += 1
        else:
            if entry.backup_path is None:
                err_console.print(
                    f"  [yellow]⚠[/yellow] No backup path recorded for '{target}'; skipping."
                )
                continue
            backup_path = Path(entry.backup_path)
            if not backup_path.exists():
                err_console.print(
                    f"  [yellow]⚠[/yellow] Backup '{backup_path}' is missing; skipping '{target}'."
                )
                continue
            try:
                if target.exists() or target.is_symlink():
                    if target.is_symlink() or target.is_file():
                        target.unlink()
                    else:
                        shutil.rmtree(target)
                shutil.move(str(backup_path), str(target))
                console.print(f"  [blue]restored[/blue] {target}")
            except OSError as exc:
                err_console.print(f"  [red]✗[/red] Could not restore '{target}': {exc}")
                errors += 1

    if errors:
        err_console.print(f"\n[red]{errors} error(s) during rollback.[/red]")
        sys.exit(1)
    else:
        console.print(f"\n[green]✓[/green] Rollback of [bold]{setup_name}[/bold] complete.")


# ---------------------------------------------------------------------------
# watch
# ---------------------------------------------------------------------------


@cli.command("watch")
@click.option("--tool", "tool_name", default=None, help="Watch and sync only to this tool.")
@click.option(
    "--project",
    is_flag=True,
    help="Also sync project-level components (rules, instructions) into current directory.",
)
def watch(tool_name: str | None, project: bool) -> None:
    """Watch the setup directory and auto-sync on file changes.

    Press Ctrl+C to stop watching.
    """
    import threading

    try:
        from watchdog.events import FileSystemEvent, FileSystemEventHandler
        from watchdog.observers import Observer
    except ImportError as exc:
        raise click.ClickException(
            "The 'watchdog' package is required for watch mode. "
            "Install it with: pip install watchdog"
        ) from exc

    from agentmux.core.sync import sync_setup

    setup = _load_setup(Path.cwd())
    tools = [tool_name] if tool_name else None
    project_dir = Path.cwd() if project else None

    watch_dir = Path.cwd()
    console.print(f"[bold]Watching[/bold] {watch_dir} for changes… ([dim]Ctrl+C to stop[/dim])")

    _debounce_timer: threading.Timer | None = None
    _lock = threading.Lock()

    def _run_sync() -> None:
        nonlocal _debounce_timer
        with _lock:
            _debounce_timer = None
        try:
            actions = sync_setup(setup, tools=tools, project_dir=project_dir)
            if actions:
                _print_sync_table(actions)
            else:
                console.print("[dim]No components to sync.[/dim]")
        except Exception as exc:
            err_console.print(f"[red]Sync error:[/red] {exc}")

    def _schedule_sync() -> None:
        nonlocal _debounce_timer
        with _lock:
            if _debounce_timer is not None:
                _debounce_timer.cancel()
            _debounce_timer = threading.Timer(0.3, _run_sync)
            _debounce_timer.daemon = True
            _debounce_timer.start()

    class _ChangeHandler(FileSystemEventHandler):
        def on_any_event(self, event: "FileSystemEvent") -> None:
            if event.is_directory:
                return
            src = Path(str(getattr(event, "src_path", "")))
            if any(part.startswith(".") for part in src.parts):
                return
            if src.name == "agentmux.toml":
                return
            if src.suffix not in _COMPONENT_SUFFIXES:
                return
            _schedule_sync()

    observer = Observer()
    observer.schedule(_ChangeHandler(), str(watch_dir), recursive=True)
    observer.start()

    try:
        while observer.is_alive():
            observer.join(timeout=1)
    except KeyboardInterrupt:
        pass
    finally:
        observer.stop()
        observer.join()
        console.print("\n[dim]Watch mode stopped.[/dim]")
