"""Auth, app, and utility commands for the Maeris CLI."""

import json
import os
import shutil
import subprocess
import sys
from pathlib import Path

import click
import structlog

from maeris_mcp.auth.oauth import AuthenticationError, OAuthFlow
from maeris_mcp.auth.token_store import TokenStore

from ._core import app_group, auth_group, cli, ui_group, _write_mcp_json
from ._utils import _repo_config_dir, _spinner

logger = structlog.get_logger(__name__)

from maeris_mcp.config import ENV as _ENV


def _find_codex_bin() -> str | None:
    """Find Codex CLI binary from PATH or common extension install locations."""
    on_path = shutil.which("codex")
    if on_path:
        return on_path

    is_win = sys.platform == "win32"
    suffix = ".exe" if is_win else ""
    search_roots: list[tuple[Path, str]] = [
        (Path.home(), f".vscode/extensions/openai.chatgpt-*/bin/*/codex{suffix}"),
        (Path.home(), f".cursor/extensions/openai.chatgpt-*/bin/*/codex{suffix}"),
        (Path.home(), f".local/bin/codex{suffix}"),
    ]

    candidates: list[Path] = []
    for root, pattern in search_roots:
        candidates.extend(root.glob(pattern))

    executable = [
        p for p in candidates
        if p.is_file() and os.access(p, os.X_OK)
    ]
    if not executable:
        return None

    # Prefer the most recently updated installation.
    latest = max(executable, key=lambda p: p.stat().st_mtime)
    return str(latest)


def _configure_codex_mcp(config_dir: Path) -> tuple[bool, str]:
    """Register Maeris MCP with Codex CLI."""
    codex_bin = _find_codex_bin()
    if not codex_bin:
        return False, "Codex CLI not found (PATH or known extension locations)."

    try:
        # Remove stale config if present (ignore errors), then add fresh entry.
        subprocess.run(
            [codex_bin, "mcp", "remove", "maeris"],
            capture_output=True,
            text=True,
        )

        add_proc = subprocess.run(
            [
                codex_bin,
                "mcp",
                "add",
                "maeris",
                "--env",
                f"MAERIS_CONFIG_DIR={config_dir}",
                "--",
                "maeris",
                "serve",
            ],
            capture_output=True,
            text=True,
        )
    except OSError as exc:
        return False, f"Could not run Codex CLI ({codex_bin}): {exc}"

    if add_proc.returncode != 0:
        err = (add_proc.stderr or add_proc.stdout or "").strip()
        return False, err or "Failed to register Codex MCP server."

    source = "PATH" if shutil.which("codex") == codex_bin else codex_bin
    return True, f"Configured via 'codex mcp add' ({source})."


def _configure_cursor_mcp(config_dir: Path) -> tuple[bool, str]:
    """Write per-repo Cursor MCP config to .cursor/mcp.json."""
    cursor_mcp_path = Path.cwd() / ".cursor" / "mcp.json"
    try:
        cursor_mcp_path.parent.mkdir(parents=True, exist_ok=True)
        config = (
            json.loads(cursor_mcp_path.read_text(encoding="utf-8"))
            if cursor_mcp_path.exists()
            else {}
        )
        servers = config.setdefault("mcpServers", {})
        servers["maeris"] = {
            "command": "maeris",
            "args": ["serve"],
            "env": {
                "MAERIS_CONFIG_DIR": str(config_dir),
            },
        }
        cursor_mcp_path.write_text(json.dumps(config, indent=2))
    except (OSError, json.JSONDecodeError) as exc:
        return False, str(exc)

    return True, f"Wrote {cursor_mcp_path.relative_to(Path.cwd())}."


@app_group.command("init")
def init() -> None:
    """Register the MCP server for the current repository.

    Writes .mcp.json to the current directory so AI clients can run the
    MCP server automatically.

    \b
    To access authenticated Maeris features, run 'maeris auth login'
    after initialising.
    """
    config_dir = _repo_config_dir()
    mcp_json_path = Path.cwd() / ".mcp.json"
    is_new = not mcp_json_path.exists()

    if not is_new:
        try:
            existing = json.loads(mcp_json_path.read_text(encoding="utf-8"))
            has_maeris = "maeris" in (existing.get("mcpServers") or {})
            other_servers = [k for k in (existing.get("mcpServers") or {}) if k != "maeris"]
        except Exception:
            has_maeris = False
            other_servers = []

        if has_maeris:
            click.echo(f"\n  Updating Maeris entry in {mcp_json_path}")
            if other_servers:
                click.echo(f"  Your other MCP servers ({', '.join(other_servers)}) are preserved.")
        else:
            click.echo(f"\n  Adding Maeris entry to existing {mcp_json_path}")
            if other_servers:
                click.echo(f"  Your other MCP servers ({', '.join(other_servers)}) are preserved.")

    _write_mcp_json(config_dir)

    click.secho("\n  ✓ Project configured", fg="green")

    _maybe_prompt_completions()
    from maeris_mcp.cli.welcome_cmds import _show_welcome
    _show_welcome()


def _maybe_prompt_completions() -> None:
    """If shell completions aren't set up yet, offer to install them."""
    shell_name = Path(os.environ.get("SHELL", "")).name
    completions: dict[str, tuple[str, str]] = {
        "zsh":  ("~/.zshrc",  'eval "$(_MAERIS_COMPLETE=zsh_source maeris)"'),
        "bash": ("~/.bashrc", 'eval "$(_MAERIS_COMPLETE=bash_source maeris)"'),
        "fish": (
            "~/.config/fish/completions/maeris.fish",
            "_MAERIS_COMPLETE=fish_source maeris | source",
        ),
    }
    if shell_name not in completions:
        return

    config_file, eval_line = completions[shell_name]
    expanded = Path(config_file).expanduser()
    already_installed = expanded.exists() and eval_line in expanded.read_text(encoding="utf-8")
    if already_installed:
        return

    click.echo()
    click.echo("─" * 50)
    click.echo()
    try:
        install = click.confirm(
            "  Set up shell autocomplete for maeris? (TAB completion)",
            default=True,
        )
    except click.Abort:
        click.echo()
        return

    if install:
        expanded.parent.mkdir(parents=True, exist_ok=True)
        with open(expanded, "a") as f:
            f.write(f"\n# maeris shell completion\n{eval_line}\n")
        click.secho(
            f"\n  ✓ Completions added to {config_file}.\n"
            f"    Activate now:  source {config_file}",
            fg="green",
        )
    click.echo()


@auth_group.command("login")
@click.option(
    "--token", "-t",
    help="Provide Firebase ID token directly (skip browser OAuth)",
)
def login(token: str | None) -> None:
    """Connect this repo to the Maeris Portal (API sync, test management).

    Authenticates via browser OAuth and stores credentials per-repo so
    each project is fully isolated. Run this from your project root.

    """
    config_dir = _repo_config_dir()
    token_store = TokenStore(config_dir=config_dir)

    mcp_json_exists = (Path.cwd() / ".mcp.json").exists()

    # Already logged in with a valid token — no need to re-auth
    if not token and token_store.is_authenticated():
        _write_mcp_json(config_dir)
        _show_login_success(token_store, not mcp_json_exists)
        _maybe_start_listener(config_dir)
        click.echo()
        click.echo("  maeris app switch              Switch to a different app")
        click.echo("  maeris app create              Create a new app")
        click.echo("  maeris auth logout             Sign out")
        return

    if token:
        # Direct token input (for testing or CI/CD)
        click.echo("Validating token...")
        try:
            token_store.save_token(token)
            _write_mcp_json(config_dir)
            _show_login_success(token_store, not mcp_json_exists)
            _maybe_start_listener(config_dir)
        except Exception as e:
            click.secho(f"✗ Invalid token: {e}", fg="red")
            sys.exit(1)
    else:
        # Browser OAuth flow
        app_url = _ENV.app_url

        click.echo("Authenticating with Maeris Portal...")
        click.echo(f"  Browser will open to: {app_url}\n")

        oauth = OAuthFlow(app_url=app_url)
        try:
            token_data = oauth.authenticate()
            token_store.save_token(
                access_token=token_data["access_token"],
                token_type=token_data.get("token_type", "Bearer"),
            )
            _write_mcp_json(config_dir)
            _show_login_success(token_store, not mcp_json_exists)
            _maybe_start_listener(config_dir)
        except TimeoutError:
            click.secho(
                "✗ Authentication timeout. Please try again.",
                fg="red",
            )
            sys.exit(1)
        except AuthenticationError as e:
            click.secho(f"✗ Authentication failed: {e}", fg="red")
            sys.exit(1)
        except Exception as e:
            logger.error("authentication_error", error=str(e), exc_info=True)
            click.secho(f"✗ Unexpected error: {e}", fg="red")
            sys.exit(1)


def _maybe_start_listener(config_dir: Path) -> None:
    """Offer to start the background listener after successful authentication."""
    from .listen_cmds import _is_listener_running, _start_listener_background, _pid_file

    pid_file = _pid_file(config_dir)
    running, existing_pid = _is_listener_running(pid_file)

    if running:
        click.secho(f"\n  Listener already running in background (PID {existing_pid}).", fg="cyan")
        return

    click.echo()
    click.echo("  The listener connects this repo to the Maeris web UI so commands")
    click.echo("  you run in the browser can access your local source code.")
    click.echo()
    try:
        start = click.confirm("  Start listener in background?", default=True)
    except click.Abort:
        click.echo()
        return

    if not start:
        click.echo("  You can start it later with: maeris listen --background")
        return

    try:
        cwd = str(Path.cwd().resolve())
        pid = _start_listener_background(cwd, config_dir)
        click.secho(f"  ✓ Listener started (PID {pid}).", fg="green")
        click.echo("  To stop:   maeris listen --stop")
    except Exception as e:
        click.secho(f"  ! Could not start listener: {e}", fg="yellow")
        click.echo("  Start it manually: maeris listen --background")



def _show_login_success(token_store: TokenStore, is_first_login: bool = True) -> None:
    """Display successful login message with user info."""
    click.secho("\n✓ Logged in!", fg="green")

    email = token_store.get_email()
    app_name = token_store.get_app_name()
    app_id = token_store.get_app_id()

    if email:
        click.echo(f"  Email: {email}")

    if app_name:
        click.secho(f"  Active app: {app_name}", fg="cyan")
    elif app_id:
        click.secho(f"  Active app: {app_id}", fg="cyan")

    if is_first_login:
        click.echo("\n  .mcp.json written — restart your editor to activate.")
    else:
        click.echo("\n  Credentials updated — no restart needed.")

    if not app_name and not app_id:
        click.echo()
        click.echo("  Next step: create your first application")
        click.echo("    maeris app create --name \"My App\" --url \"https://myapp.com\"")
    else:
        click.echo()
        click.echo("  What's next:")
        click.echo("    maeris test generate")
        click.echo("    maeris test list")
        click.echo("    maeris coverage ui")


@auth_group.command("logout")
def logout() -> None:
    """Sign out and remove stored credentials for the current repository."""
    config_dir = _repo_config_dir()
    token_store = TokenStore(config_dir=config_dir)

    if not token_store.is_authenticated():
        click.echo("Not currently authenticated for this repository.")
        return

    token_store.clear()
    click.secho("✓ Signed out successfully", fg="green")
    click.echo(f"  Credentials removed from {config_dir}")


@auth_group.command("status")
def status() -> None:
    """Show authentication status for the current repository."""
    config_dir = _repo_config_dir()
    token_store = TokenStore(config_dir=config_dir)

    if not token_store.is_authenticated():
        click.secho("Not authenticated for this repository.", fg="yellow")
        click.echo("\nRun ", nl=False)
        click.secho("maeris auth login", fg="cyan", bold=True, nl=False)
        click.echo(" from your project root to authenticate.")
        return

    if token_store.is_token_expired():
        click.secho("⚠ Token expired", fg="yellow")
        click.echo("Run ", nl=False)
        click.secho("maeris auth login", fg="cyan", bold=True, nl=False)
        click.echo(" to re-authenticate.")
        return

    email = token_store.get_email()
    app_name = token_store.get_app_name()

    click.secho("✓ Authenticated", fg="green")
    click.echo()

    if email:
        click.echo(f"  Email:       {email}")
    if app_name:
        click.echo(f"  Active app:  {app_name}")

    # Show plan info if an app is selected
    if token_store.get_app_id():
        import asyncio

        async def _fetch_plan() -> tuple[dict, list[dict]]:
            from maeris_mcp.maeris.client import MaerisClient
            async with MaerisClient() as client:
                active = await client.get_active_plan()
                plans = await client.get_plans()
                return active, plans

        try:
            with _spinner("Fetching plan info…"):
                active_plan, all_plans = asyncio.run(_fetch_plan())
            plan_id = active_plan.get("plan_id")
            plan_name = None
            for p in all_plans:
                if p.get("plan_id") == plan_id or p.get("id") == plan_id:
                    plan_name = p.get("name") or p.get("plan_name")
                    break
            status_str = active_plan.get("status", "unknown")
            click.echo(f"  Plan:        {plan_name or plan_id or '—'} ({status_str})")
        except Exception as e:
            logger.warning("plan_fetch_failed", error=str(e))

    click.echo(f"  Credentials: {token_store.credentials_file}")


@app_group.command("create")
@click.option("--name", "-n", default=None, help="Application name.")
@click.option("--url", "-u", default=None, help="Application URL.")
def create_app(name: str | None, url: str | None) -> None:
    """Create a new application and set it as active.

    \b
    Examples:
      maeris app create --name "My App" --url "https://myapp.com"
      maeris app create   # prompts for name and url
    """
    import asyncio

    config_dir = _repo_config_dir()
    token_store = TokenStore(config_dir=config_dir)

    if not token_store.is_authenticated() or token_store.is_token_expired():
        click.echo("Not logged in. Starting login...")
        app_url = _ENV.app_url
        click.echo(f"  Browser will open to: {app_url}\n")

        oauth = OAuthFlow(app_url=app_url)
        try:
            token_data = oauth.authenticate()
            token_store.save_token(
                access_token=token_data["access_token"],
                token_type=token_data.get("token_type", "Bearer"),
            )
            _write_mcp_json(config_dir)
            click.secho("✓ Logged in successfully.\n", fg="green")
        except TimeoutError:
            click.secho("✗ Authentication timeout. Please try again.", fg="red")
            sys.exit(1)
        except AuthenticationError as e:
            click.secho(f"✗ Authentication failed: {e}", fg="red")
            sys.exit(1)
        except Exception as e:
            click.secho(f"✗ Login failed: {e}", fg="red")
            sys.exit(1)

    if not name:
        name = click.prompt("Application name")
    if not url:
        url = click.prompt("Application URL")

    async def _create() -> dict:
        from maeris_mcp.maeris.client import MaerisClient
        async with MaerisClient() as client:
            return await client.create_application(name, url)

    try:
        with _spinner(f"Creating application '{name}'…"):
            result = asyncio.run(_create())
    except Exception as e:
        click.secho(f"✗ Failed to create application: {e}", fg="red")
        sys.exit(1)

    app_id = result.get("application_id", "")
    if not app_id:
        click.secho("✗ Application created but no ID returned.", fg="red")
        click.secho(f"  Raw response: {result}", fg="yellow")
        sys.exit(1)

    token_store.save_app_id(app_id, name)
    click.secho(f"\n✓ Application created: {name}", fg="green")
    click.echo(f"  App ID: {app_id}")
    click.echo(f"  URL:    {url}")
    click.echo("  Set as active application for this repository.")




@app_group.command("switch")
@click.option("--name", "-n", "app_name", default=None, help="Switch by application name.")
@click.option("--url", "-u", "app_url", default=None, help="Switch by application URL.")
def switch_app(app_name: str | None, app_url: str | None) -> None:
    """Switch the active application for the current repository.

    \b
    Examples:
      maeris app switch --name "My App"
      maeris app switch --url "https://myapp.com"
      maeris app switch   # interactive picker
    """
    import asyncio

    config_dir = _repo_config_dir()
    token_store = TokenStore(config_dir=config_dir)

    if not token_store.is_authenticated():
        click.secho("✗ Not authenticated. Run 'maeris auth login' first.", fg="red")
        sys.exit(1)

    if token_store.is_token_expired():
        click.secho("✗ Token expired. Run 'maeris auth login' to re-authenticate.", fg="red")
        sys.exit(1)

    async def _fetch() -> list[dict]:
        from maeris_mcp.maeris.client import MaerisClient
        async with MaerisClient() as client:
            return await client.get_applications()

    try:
        with _spinner("Fetching applications…"):
            apps = asyncio.run(_fetch())
    except Exception as e:
        click.secho(f"✗ Failed to fetch applications: {e}", fg="red")
        sys.exit(1)

    if not apps:
        click.secho("✗ No applications found for your account.", fg="yellow")
        sys.exit(1)

    selected = None

    # Non-interactive: match by --name
    if app_name:
        needle = app_name.lower()
        selected = next(
            (a for a in apps if (a.get("name") or "").lower() == needle), None
        ) or next(
            (a for a in apps if (a.get("name") or "").lower().startswith(needle)), None
        )
        if not selected:
            click.secho(f"✗ No application matching name '{app_name}'.", fg="red")
            sys.exit(1)

    # Non-interactive: match by --url (exact, then domain-based)
    if not selected and app_url:
        needle_url = app_url.rstrip("/").lower()
        selected = next(
            (a for a in apps if (a.get("url") or "").rstrip("/").lower() == needle_url), None
        )
        if not selected:
            selected = next(
                (a for a in apps if needle_url.startswith((a.get("url") or "").rstrip("/").lower())
                 or (a.get("url") or "").rstrip("/").lower().startswith(needle_url)), None
            )
        if not selected:
            click.secho(f"✗ No application matching URL '{app_url}'.", fg="red")
            sys.exit(1)

    # Interactive fallback
    if not selected:
        click.echo()
        current_app_id = token_store.get_app_id()
        for i, app in enumerate(apps, 1):
            aid = app.get("appid", "")
            aname = app.get("name") or aid
            marker = " (current)" if aid == current_app_id else ""
            click.echo(f"  {i}) {aname}{click.style(marker, fg='cyan')}")

        click.echo()

        try:
            raw = click.prompt("Select application (number or name)").strip()
        except click.Abort:
            click.echo("\nAborted.")
            sys.exit(0)

        if raw.isdigit():
            idx = int(raw) - 1
            if 0 <= idx < len(apps):
                selected = apps[idx]
            else:
                click.secho(f"✗ Number out of range. Enter 1-{len(apps)}.", fg="red")
                sys.exit(1)
        else:
            raw_lower = raw.lower()
            # Try name match (exact then prefix)
            selected = next(
                (a for a in apps if (a.get("name") or "").lower() == raw_lower), None
            ) or next(
                (a for a in apps if (a.get("name") or "").lower().startswith(raw_lower)), None
            )
            # Try URL match (exact, then domain-based)
            if not selected:
                raw_url = raw.rstrip("/").lower()
                selected = next(
                    (a for a in apps if (a.get("url") or "").rstrip("/").lower() == raw_url), None
                )
            if not selected:
                selected = next(
                    (a for a in apps if raw_url.startswith((a.get("url") or "").rstrip("/").lower())
                     or (a.get("url") or "").rstrip("/").lower().startswith(raw_url)), None
                )
            if not selected:
                click.secho(f"✗ No application matching '{raw}'.", fg="red")
                sys.exit(1)

    sel_id = selected.get("appid", "")
    sel_name = selected.get("name") or sel_id

    if not sel_id:
        click.secho("✗ Selected application has no ID.", fg="red")
        click.secho(f"  Raw response: {selected}", fg="yellow")
        sys.exit(1)

    token_store.save_app_id(sel_id, sel_name)
    click.secho(f"\n✓ Switched to: {sel_name}", fg="green")
    click.echo(f"  App ID: {sel_id}")




@app_group.command("plan")
def check_plan() -> None:
    """Show the active subscription plan for the current application."""
    import asyncio

    config_dir = _repo_config_dir()
    token_store = TokenStore(config_dir=config_dir)

    if not token_store.is_authenticated():
        click.secho("✗ Not authenticated. Run 'maeris auth login' first.", fg="red")
        sys.exit(1)

    if token_store.is_token_expired():
        click.secho("✗ Token expired. Run 'maeris auth login' to re-authenticate.", fg="red")
        sys.exit(1)

    if not token_store.get_app_id():
        click.secho("✗ No active application. Run 'maeris switch-app' first.", fg="red")
        sys.exit(1)

    app_name = token_store.get_app_name() or token_store.get_app_id()
    async def _fetch() -> tuple[dict, list[dict]]:
        from maeris_mcp.maeris.client import MaerisClient
        async with MaerisClient() as client:
            active = await client.get_active_plan()
            plans = await client.get_plans()
            return active, plans

    try:
        with _spinner(f"Checking plan for '{app_name}'…"):
            active_plan, all_plans = asyncio.run(_fetch())
    except Exception as e:
        click.secho(f"✗ Failed to fetch plan info: {e}", fg="red")
        sys.exit(1)

    # Resolve plan name from available plans
    plan_id = active_plan.get("plan_id")
    plan_name = None
    for p in all_plans:
        if p.get("plan_id") == plan_id or p.get("id") == plan_id:
            plan_name = p.get("name") or p.get("plan_name")
            break

    status = active_plan.get("status", "unknown")
    subscribed_on = active_plan.get("subscribed_on", "—")
    expires_on = active_plan.get("expires_on", "—")

    click.echo()
    click.secho(f"  Application:   {app_name}", bold=True)
    click.echo(f"  Plan:          {plan_name or plan_id or '—'}")
    click.echo(f"  Status:        {status}")
    click.echo(f"  Subscribed on: {subscribed_on}")
    click.echo(f"  Expires on:    {expires_on}")


@cli.command(hidden=True)
@click.option("--mode", "-m", type=click.Choice(["stdio", "http"]), default="stdio")
@click.option("--port", "-p", default=8080, help="HTTP mode port (default: 8080)")
def serve(mode: str, port: int) -> None:
    """Run MCP server (called automatically by your editor via .mcp.json)."""
    # Import here to avoid circular dependencies
    from maeris_mcp.main import main

    # In stdio mode, stdout is the JSON-RPC transport — never print to it.
    if mode == "http":
        click.echo(f"Starting Maeris MCP server on port {port}...")

    # Run the MCP server — pass args explicitly so Click doesn't re-parse sys.argv
    args = ["--mode", mode, "--port", str(port)]
    main(args, standalone_mode=True)


@app_group.command("link")
@click.argument("path")
@click.option("--label", "-l", default=None, help="Display name for this repo (default: directory name).")
def link_cmd(path: str, label: str | None) -> None:
    """Link another repository as a context source for test generation.

    PATH is the relative or absolute path to the repository you want to link.

    When tests are generated for the current repo, source files from linked
    repos (API routes, schemas, models, auth middleware) are automatically
    included as supplementary context so that tests are more accurate.

    \b
    Examples:
      maeris app link ../backend-api
      maeris app link /abs/path/to/service --label "Auth Service"
    """
    from pathlib import Path as _Path
    from maeris_mcp.auth.linked_repo_store import LinkedRepoStore

    resolved = _Path(path).expanduser().resolve()
    if not resolved.exists():
        click.echo(f"Error: path does not exist: {resolved}", err=True)
        raise SystemExit(1)
    if not resolved.is_dir():
        click.echo(f"Error: path is not a directory: {resolved}", err=True)
        raise SystemExit(1)

    store = LinkedRepoStore()
    entry = store.add(str(resolved), label)
    click.echo(f"Linked  {entry['label']!r}  →  {entry['path']}")
    click.echo("Source files from this repo will be included as context when generating tests.")


@app_group.command("unlink")
@click.argument("path_or_label")
def unlink_cmd(path_or_label: str) -> None:
    """Remove a linked repository.

    PATH_OR_LABEL is the path you passed to 'maeris app link', or the --label value.

    \b
    Examples:
      maeris app unlink ../backend-api
      maeris app unlink "Auth Service"
    """
    from maeris_mcp.auth.linked_repo_store import LinkedRepoStore

    store = LinkedRepoStore()
    removed = store.remove(path_or_label)
    if removed:
        click.echo(f"Unlinked: {path_or_label}")
    else:
        click.echo(f"No linked repo found matching: {path_or_label!r}", err=True)
        raise SystemExit(1)


@ui_group.command("tidy")
@click.option(
    "--name", "-n",
    "attr_name",
    default="data-automation-id",
    show_default=True,
    help="Attribute name to inject.",
)
@click.option(
    "--target",
    "-t",
    default=None,
    help="Directory to scan (default: cwd).",
)
@click.option(
    "--dry-run", "-d",
    is_flag=True,
    default=False,
    help="Preview changes without writing any files.",
)
@click.option(
    "--yes",
    "-y",
    "auto_confirm",
    is_flag=True,
    default=False,
    help="Skip confirmation prompt.",
)
def tidy_cmd(attr_name: str, target: str | None, dry_run: bool, auto_confirm: bool) -> None:
    """Inject automation attributes onto interactable elements for reliable test selectors.

    \b
    Scans .jsx/.tsx/.html/.vue/.svelte files and adds a unique
    data-automation-id (or --name) to every <button>, <input>,
    <select>, <textarea>, and <a> that doesn't already have it.

    \b
    Examples:
      maeris tidy                          # scan cwd, confirm before writing
      maeris tidy --name data-testid       # use a different attribute name
      maeris tidy --target src/ --dry-run  # preview only
      maeris tidy -y                       # apply without confirmation
    """
    from pathlib import Path
    from maeris_mcp.tidy import scan, apply_patches

    scan_root = Path(target) if target else Path.cwd()

    if not scan_root.is_dir():
        click.secho(f"- Not a directory: {scan_root}", fg="red")
        sys.exit(1)

    click.echo(f"Scanning {click.style(str(scan_root), bold=True)} for interactable elements...")
    click.echo()

    summaries = scan(scan_root, attr_name=attr_name)

    if not summaries:
        click.secho("  No supported files found.", fg="yellow")
        sys.exit(0)

    # --- Summary table ---
    total_elements = sum(s.total_found for s in summaries)
    total_tagged = sum(s.already_tagged for s in summaries)
    total_to_add = sum(s.to_update for s in summaries)
    files_with_changes = [s for s in summaries if s.to_update > 0]

    # Display per-file breakdown
    col_w = max(len(str(s.file.relative_to(scan_root))) for s in summaries) + 2
    for s in summaries:
        rel = str(s.file.relative_to(scan_root))
        if s.to_update == 0:
            note = click.style(f"  (all {s.already_tagged} already tagged)", fg="bright_black")
            click.echo(f"  {rel:<{col_w}}{note}")
        else:
            added = click.style(f"{s.to_update} to tag", fg="cyan")
            skipped = f"  ({s.already_tagged} already tagged)" if s.already_tagged else ""
            click.echo(f"  {rel:<{col_w}}{added}{click.style(skipped, fg='bright_black')}")

    click.echo()

    if total_to_add == 0:
        click.secho(
            f"  Nothing to do — all {total_tagged} element(s) already have {attr_name!r}.",
            fg="green",
        )
        sys.exit(0)

    summary_line = (
        f"  {len(files_with_changes)} file(s) · "
        f"{click.style(str(total_to_add), bold=True)} attribute(s) to add"
    )
    if total_tagged:
        summary_line += f"  ({total_tagged} already tagged, skipped)"
    click.echo(summary_line)
    click.echo()

    if dry_run:
        click.secho("  [dry-run] No files written.", fg="yellow")
        # Show a sample of what would be added
        sample_limit = 5
        shown = 0
        for s in files_with_changes:
            if shown >= sample_limit:
                break
            rel = str(s.file.relative_to(scan_root))
            for patch in s.patches:
                if shown >= sample_limit:
                    break
                orig_short = patch.original[:80].rstrip()
                click.echo(f"  {click.style(rel, fg='bright_black')}")
                click.echo(f"    {click.style('-', fg='red')} {orig_short}")
                click.echo(f"    {click.style('+', fg='green')} {patch.patched[:80].rstrip()}")
                click.echo()
                shown += 1
        remaining = total_to_add - shown
        if remaining > 0:
            click.secho(f"  … and {remaining} more.", fg="bright_black")
        sys.exit(0)

    # --- Confirmation ---
    if not auto_confirm:
        try:
            ok = click.confirm("  Apply changes?", default=False)
        except click.Abort:
            click.echo("\nAborted.")
            sys.exit(0)
        if not ok:
            click.echo("Cancelled.")
            sys.exit(0)

    # --- Apply ---
    modified = apply_patches(files_with_changes)
    click.secho(
        f"\n  ✓ {modified} file(s) modified · {total_to_add} attribute(s) added",
        fg="green",
    )


# completions command moved to config_cmds.py → maeris config completions

# ---------------------------------------------------------------------------
# Top-level shortcut aliases: maeris login / logout / status
# ---------------------------------------------------------------------------

# Top-level aliases — login/logout/status go to the Portal auth flow.
cli.add_command(login, name="login")
cli.add_command(logout, name="logout")
cli.add_command(status, name="status")
cli.add_command(init, name="init")
cli.add_command(create_app, name="create-app")
cli.add_command(switch_app, name="switch-app")


@cli.command("unlink", hidden=True, context_settings={"ignore_unknown_options": True})
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
def _unlink_redirect(args: tuple) -> None:
    """Hidden redirect: maeris unlink → maeris app unlink."""
    suffix = f" {' '.join(args)}" if args else " <path-or-label>"
    click.secho(
        f"  Did you mean: maeris app unlink{suffix}",
        fg="yellow",
    )
    raise SystemExit(1)


@app_group.command("scope")
@click.option(
    "--maeris", "maeris_scope",
    type=click.Choice(["read", "write", "full"]),
    default=None,
    help="Set the Maeris portal permission scope.",
)
@click.option(
    "--codebase", "codebase_scope",
    type=click.Choice(["read", "write", "execute"]),
    default=None,
    help="Set the codebase access scope (Maeris AI mode).",
)
def scope_cmd(maeris_scope: str | None, codebase_scope: str | None) -> None:
    """View or change permission scopes for this repository.

    \b
    Without options, displays current scopes.
    With options, updates the specified scope(s) and rewrites .mcp.json.

    \b
    Examples:
      maeris app scope                          # show current scopes
      maeris app scope --maeris read            # portal: view-only
      maeris app scope --codebase execute       # codebase: full access
      maeris app scope --maeris full --codebase write
    """
    from maeris_mcp.auth.scopes import ScopeConfig

    config_dir = _repo_config_dir()
    scope_cfg = ScopeConfig(config_dir)
    current = scope_cfg.load()

    if maeris_scope is None and codebase_scope is None:
        # Display mode
        click.echo()
        click.echo("  Permission scopes for this repository:")
        click.echo()
        click.secho(f"  Maeris portal:  {current['maeris_scope']}", fg="cyan")
        click.secho(f"  Codebase:       {current['codebase_scope']}", fg="cyan")
        click.echo()
        click.echo("  Change with: maeris app scope --maeris <level> --codebase <level>")
        return

    new_maeris = maeris_scope or current["maeris_scope"]
    new_codebase = codebase_scope or current["codebase_scope"]

    scope_cfg.save(new_maeris, new_codebase)
    _write_mcp_json(config_dir, maeris_scope=new_maeris, codebase_scope=new_codebase)

    click.echo()
    click.secho("  Scopes updated:", fg="green")
    click.secho(f"  Maeris portal:  {new_maeris}", fg="cyan")
    click.secho(f"  Codebase:       {new_codebase}", fg="cyan")
    click.echo()
    click.echo("  .mcp.json updated — restart your AI client to apply.")

    from .listen_cmds import _is_listener_running, _pid_file
    if _is_listener_running(_pid_file(config_dir))[0]:
        click.echo()
        click.secho(
            "  ⚠ Maeris listener is running with the old scopes.",
            fg="yellow",
        )
        click.echo("  Run: maeris listen --background --stop && maeris listen --background")




