"""Ethiack Beacon CLI - main entry point."""

from __future__ import annotations

import os
import signal
import sys
import time
from typing import Optional

import click
import typer
from dotenv import load_dotenv
from rich.console import Console
from rich.prompt import Confirm, Prompt
from rich.status import Status

import questionary

from . import __version__
from .api import BeaconAPI, report_health
from .config import LocalBeacon, LocalState, Settings
from .console import (
    console,
    error,
    info,
    print_banner,
    print_beacon_detail,
    print_beacon_table,
    print_confirm_panel,
    status_badge,
    step,
    success,
    warning,
)
from .exceptions import BeaconAPIError, BeaconConfigError, BeaconSystemError
from . import dns as dns_mgr
from . import rathole as rathole_mgr
from . import wireguard as wg
from .system import (
    check_wireguard,
    find_free_udp_port,
    get_dns_servers,
    get_interfaces,
    get_suggested_beacon_name,
    validate_cidrs_input,
    validate_name_input,
)

app = typer.Typer(
    name="beacon",
    help="Ethiack Beacon - manage secure network tunnels.",
    no_args_is_help=True,
    pretty_exceptions_enable=False,
)


# Helpers

def _settings_for_runtime() -> Settings:
    """Load Settings from env without validating API key/secret.

    Use for commands that only need api_url (health) or no settings at all
    (start from local state). API key/secret are not required at runtime.
    """
    load_dotenv(override=False)
    return Settings.from_env()


def _settings() -> Settings:
    s = _settings_for_runtime()
    try:
        s.validate()
    except BeaconConfigError as exc:
        error(str(exc))
        raise typer.Exit(1)
    return s


def _api(s: Settings) -> BeaconAPI:
    return BeaconAPI(s)


def _abort(msg: str) -> None:
    error(msg)
    raise typer.Exit(1)


def _resolve_org(s: Settings, non_interactive: bool = False) -> None:
    """Fetch organizations and store the selected org_id in *s*.

    - If ETHIACK_ORG_ID is already set, use it without any API call.
    - If the account has exactly one org, auto-select it.
    - Otherwise prompt the user to pick (or auto-select the first in non-interactive mode).
    """
    if s.org_id:
        return

    try:
        with BeaconAPI(s) as api:
            orgs = api.get_organizations()
    except BeaconAPIError as exc:
        _abort(f"Failed to fetch organizations: {exc}")

    if not orgs:
        _abort("No organizations found for your account.")

    if len(orgs) == 1:
        s.org_id = str(orgs[0]["id"])
        info(f"Organization: [bold]{orgs[0]['name']}[/bold]")
        return

    # Multiple orgs - auto-select first in non-interactive mode, otherwise prompt
    if non_interactive:
        s.org_id = str(orgs[0]["id"])
        info(f"Auto-selected organization: [bold]{orgs[0]['name']}[/bold]")
        return

    choices = [
        questionary.Choice(title=f"{o['name']}  ({o['id']})", value=str(o["id"]))
        for o in orgs
    ]
    selected = questionary.select("Select organization:", choices=choices).ask()
    if not selected:
        raise typer.Exit(0)
    s.org_id = selected


# version

@app.command()
def version() -> None:
    """Show CLI version."""
    console.print(f"ethiack-beacon [cyan]{__version__}[/cyan]")


# Helpers

def _inject_wg_listen_port(wg_config: str, listen_port: int) -> str:
    """Inject or replace ListenPort in the [Interface] section of a WireGuard config.

    The API-generated client config has no ListenPort, so wg-quick picks an
    arbitrary port. Rathole forwards incoming UDP to 127.0.0.1:{listen_port},
    so WireGuard must listen on exactly that port.
    """
    import re

    if re.search(r"(?m)^ListenPort\s*=", wg_config):
        return re.sub(r"(?m)^ListenPort\s*=\s*\d+", f"ListenPort = {listen_port}", wg_config)
    # Insert after the PrivateKey line inside [Interface]
    return re.sub(
        r"(?m)^(PrivateKey\s*=\s*.+)",
        rf"\1\nListenPort = {listen_port}",
        wg_config,
        count=1,
    )


def _fix_wg_masquerade_iface(wg_config: str) -> str:
    """Replace hardcoded MASQUERADE interface in PostUp/PostDown with a
    interface-agnostic rule that masquerades on whichever interface the packet
    exits through.

    The API-generated config references a specific interface (e.g. 'en0') that
    may not exist or may not be the correct outbound interface in the deployment
    environment (e.g. Docker uses 'eth0'; a multi-homed host may route different
    CIDRs through different interfaces). Replacing '-o <iface>' with '! -o %i'
    lets iptables masquerade on any outbound interface except the WireGuard
    tunnel itself, which is correct in all cases.
    """
    import re

    def _fix_line(line: str) -> str:
        if not re.match(r"\s*Post(?:Up|Down)\s*=", line, re.IGNORECASE):
            return line
        return re.sub(r"(?:!\s+)?-o\s+\S+\s+(-j MASQUERADE)", r"! -o %i \1", line)

    fixed = "\n".join(_fix_line(line) for line in wg_config.splitlines())
    if wg_config.endswith("\n"):
        fixed += "\n"
    return fixed


def _get_wg_gateway_ip(wg_config: str) -> str:
    """Extract the beacon's WireGuard IP from the [Interface] Address field."""
    import re
    m = re.search(r"(?m)^Address\s*=\s*([\d.]+)", wg_config)
    return m.group(1) if m else "10.252.0.1"


# Pentest helpers

def _create_pentest_and_add_cidrs(
    s: Settings,
    org_id: str,
    beacon_id: str,
    beacon_name: str,
    cidrs: list[str],
) -> None:
    """Create a new pentest and add the beacon CIDRs to its scope."""
    # Get org name to build the pentest name
    try:
        with _api(s) as api:
            orgs = api.get_organizations()
        org_name = next((o["name"] for o in orgs if str(o["id"]) == org_id), org_id)
    except BeaconAPIError:
        org_name = org_id

    pentest_name = f"{org_name} - {beacon_name}"

    console.print()
    with Status("[cyan]Creating pentest…[/cyan]", console=console):
        try:
            with _api(s) as api:
                pentest_data = api.create_pentest(
                    organization_id=org_id,
                    name=pentest_name,
                    add_assets=False,
                    launch_recon=False,
                )
        except BeaconAPIError as exc:
            if exc.status_code in (402, 403, 404):
                info("Pentest creation is not available for this organization: skipping.")
            else:
                warning(f"Failed to create pentest: {exc}")
            return

    pentest_slug = pentest_data.get("slug") or str(pentest_data.get("id", ""))
    if not pentest_slug:
        warning("Pentest created but no slug or ID returned")
        return

    step(f"Pentest [bold]{pentest_name}[/bold] created")
    _add_cidrs_to_pentest(
        s=s,
        org_id=org_id,
        beacon_id=beacon_id,
        cidrs=cidrs,
        pentest_slug=pentest_slug,
        non_interactive=True,
    )

    with Status("[cyan]Starting pentest…[/cyan]", console=console):
        try:
            with _api(s) as api:
                api.start_pentest(pentest_slug)
        except BeaconAPIError as exc:
            warning(f"Failed to start pentest: {exc}")
            return
    step(f"Pentest [bold]{pentest_name}[/bold] started")


def _add_cidrs_to_pentest(
    s: Settings,
    org_id: str,
    beacon_id: str,
    cidrs: list[str],
    pentest_slug: Optional[str],
    non_interactive: bool,
    beacon_name: Optional[str] = None,
) -> None:
    """Optionally create CIDR assets and add them to an existing pentest's scope."""
    # Non-interactive: only proceed if an event slug was provided
    if non_interactive:
        if not pentest_slug:
            return
    else:
        console.print()
        info(
            "Adding the CIDRs to a pentest scope will trigger automatic testing "
            "of all reachable hosts. You can also add them manually in the Ethiack Portal at any time."
        )
        scope_choices = []
        if beacon_name:
            scope_choices.append(questionary.Choice("Create a new pentest", value="create"))
        scope_choices.append(questionary.Choice("Add to an existing pentest", value="existing"))
        scope_choices.append(questionary.Choice("Skip", value="skip"))
        pentest_action = questionary.select("Pentest scope:", choices=scope_choices).ask()

        if pentest_action == "create":
            _create_pentest_and_add_cidrs(
                s=s,
                org_id=org_id,
                beacon_id=beacon_id,
                beacon_name=beacon_name,
                cidrs=cidrs,
            )
            return
        elif pentest_action != "existing":
            return

        if not pentest_slug:
            # Try to list events so the user can pick one
            try:
                with _api(s) as api:
                    events = api.get_events(organization_id=org_id)
            except BeaconAPIError:
                events = []

            if events:
                choices = [
                    questionary.Choice(
                        title=f"{e.get('slug', e.get('name', 'unknown'))}",
                        value=e.get("slug"),
                    )
                    for e in events
                    if e.get("slug")
                ]
                if choices:
                    pentest_slug = questionary.select(
                        "Select pentest:", choices=choices
                    ).ask()
                else:
                    pentest_slug = Prompt.ask("Pentest slug")
            else:
                pentest_slug = Prompt.ask("Pentest slug")

        if not pentest_slug:
            return

    console.print()
    info(f"Adding CIDRs to pentest [bold]{pentest_slug}[/bold]…")

    for cidr in cidrs:
        try:
            with _api(s) as api:
                asset_data = api.create_asset(
                    organization_id=org_id,
                    asset_value=cidr,
                    asset_type="cidr",
                    beacon_id=beacon_id,
                )
            asset_id = asset_data.get("id")
            if not asset_id:
                warning(f"Could not create asset for {cidr} - no ID in response")
                continue
        except BeaconAPIError as exc:
            if exc.status_code == 409:
                warning(f"{cidr}: asset already exists, skipping")
            else:
                warning(f"Failed to create asset for {cidr}: {exc}")
            continue

        try:
            with _api(s) as api:
                api.add_asset_to_event(pentest_slug=pentest_slug, asset_id=asset_id)
            step(f"[bold]{cidr}[/bold] added to pentest scope")
        except BeaconAPIError as exc:
            if exc.status_code == 409:
                info(f"{cidr}: already in pentest scope")
            else:
                warning(f"Failed to add {cidr} to pentest: {exc}")


# create

@app.command()
def create(
    name: Optional[str] = typer.Option(
        None, "--name", "-n", envvar="ETHIACK_BEACON_NAME", help="Beacon name"
    ),
    cidrs: Optional[str] = typer.Option(
        None, "--cidrs", "-c", envvar="ETHIACK_BEACON_CIDRS",
        help="Comma-separated allowed CIDRs (e.g. 192.168.1.0/24,10.0.0.0/8)"
    ),
    create_pentest: bool = typer.Option(
        True, "--create-pentest/--no-create-pentest", envvar="ETHIACK_CREATE_PENTEST",
        help="Create a new pentest for this beacon and add the CIDRs to its scope (default: enabled)"
    ),
    pentest_slug: Optional[str] = typer.Option(
        None, "--pentest-slug", "-p", envvar="ETHIACK_PENTEST_SLUG",
        help="Existing pentest slug to add the CIDRs to as scope assets (overrides --create-pentest)"
    ),
    assume_detected_cidrs: bool = typer.Option(
        False, "--assume-detected-cidrs", envvar="ETHIACK_ASSUME_DETECTED_CIDRS",
        help="Auto-use CIDRs detected from local interfaces without prompting (useful for Docker/k8s)"
    ),
    skip_pentest: bool = typer.Option(
        False, "--skip-pentest-config", envvar="ETHIACK_SKIP_PENTEST_CONFIG",
        help="Skip all pentest configuration (ignores --create-pentest and --pentest-slug)"
    ),
    wg_port: Optional[int] = typer.Option(
        None, "--wg-port", envvar="ETHIACK_BEACON_WG_PORT",
        help="Local WireGuard UDP port (auto-detected if omitted)"
    ),
    yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompts"),
) -> None:
    """Create and start a new beacon on this machine."""
    print_banner(__version__)

    s = _settings()
    non_interactive = yes or _all_provided(name, cidrs)
    _resolve_org(s, non_interactive)

    # WireGuard check
    if not check_wireguard():
        error(
            "WireGuard not found. Install it first:\n"
            "  https://www.wireguard.com/install/"
        )
        raise typer.Exit(1)

    # Ensure sudo credentials are cached before any spinners start, so the
    # password prompt is visible on the terminal.
    if wg._sudo_available():
        info("WireGuard requires [bold]sudo[/bold] to manage network interfaces. You may be prompted for your password.")
    try:
        wg.prompt_sudo()
    except BeaconSystemError as exc:
        _abort(str(exc))

    # Collect name
    if not name:
        hostname_default = get_suggested_beacon_name()
        name = questionary.text(
            "Beacon name:",
            default=hostname_default,
            validate=validate_name_input,
        ).ask()
        if not name:
            raise typer.Exit(0)
    else:
        err = validate_name_input(name)
        if err is not True:
            _abort(err)

    # Collect CIDRs
    if not cidrs:
        if non_interactive:
            if assume_detected_cidrs:
                detected: list[str] = []
                seen: set[str] = set()
                for iface in get_interfaces():
                    for c in iface.cidrs:
                        if c not in seen:
                            seen.add(c)
                            detected.append(c)
                if not detected:
                    _abort(
                        "ETHIACK_ASSUME_DETECTED_CIDRS is set but no network interfaces were detected."
                    )
                selected_cidrs = detected
                info(f"Auto-detected CIDRs: {', '.join(selected_cidrs)}")
            else:
                _abort("No CIDRs provided. Set ETHIACK_BEACON_CIDRS or use --cidrs.")
        else:
            # Auto-detect private CIDRs from all interfaces as a starting point
            detected_cidrs: list[str] = []
            seen: set[str] = set()
            for iface in get_interfaces():
                for c in iface.cidrs:
                    if c not in seen:
                        seen.add(c)
                        detected_cidrs.append(c)

            raw = questionary.text(
                "CIDRs to expose through this beacon:",
                default=", ".join(detected_cidrs),
                validate=validate_cidrs_input,
            ).ask()
            if not raw:
                raise typer.Exit(0)
            selected_cidrs = [c.strip() for c in raw.split(",") if c.strip()]
    else:
        selected_cidrs = [c.strip() for c in cidrs.split(",") if c.strip()]
        err = validate_cidrs_input(cidrs)
        if err is not True:
            _abort(err)

    # WireGuard port
    if not wg_port:
        wg_port = find_free_udp_port()

    # Confirmation
    if not non_interactive:
        print_confirm_panel(
            "Beacon configuration",
            [
                ("Name", name),
                ("CIDRs", ", ".join(selected_cidrs)),
                ("Beacon local port", str(wg_port)),
            ],
        )
        if not Confirm.ask("[cyan]Create beacon?[/cyan]", default=True):
            raise typer.Exit(0)

    # API: create beacon (or recover if name already exists)
    with Status("[cyan]Creating beacon on Ethiack Portal…[/cyan]", console=console):
        try:
            with _api(s) as api:
                data = api.create(
                    name=name,
                    allowed_cidrs=selected_cidrs,
                )
        except BeaconAPIError as exc:
            if exc.status_code != 409:
                _abort(str(exc))
            # 409: beacon with this name already exists (e.g. container restart
            # where local state was lost). Find it by name and recover.
            try:
                with _api(s) as api:
                    results = api.list().get("results", [])
                    match = next((b for b in results if b.get("name") == name), None)
                    if not match:
                        _abort(
                            f"Beacon '{name}' already exists (409) but was not found "
                            "in the portal listing. Delete it manually and retry."
                        )
                    data = api.get(str(match["id"]))
            except BeaconAPIError as list_exc:
                _abort(str(list_exc))
            step(f"Beacon '{name}' already exists - resuming (ID {data['id']})")

    beacon_id: str = str(data["id"])
    wg_interface: str = f"wg{beacon_id}"  # tunnel interface, distinct from masquerade interface
    wg_config: str = data["wireguard_config"]
    rathole_server: str = data["rathole_server"]
    rathole_control_port: int = data["rathole_control_port"]
    rathole_public_port: int = data["rathole_public_port"]
    beacon_token: str = data["beacon_token"]

    wg_config = _inject_wg_listen_port(wg_config, wg_port)
    wg_config = _fix_wg_masquerade_iface(wg_config)
    gateway_ip = _get_wg_gateway_ip(wg_config)

    # Set DNS in API so client WireGuard configs include DNS = <gateway>
    with Status("[cyan]Configuring beacon DNS…[/cyan]", console=console):
        try:
            with _api(s) as api:
                api.update(beacon_id, dns_server=gateway_ip)
        except BeaconAPIError:
            pass  # Non-fatal; tunnel still works without DNS in client configs

    # Capture system DNS now - before wg-quick overwrites /etc/resolv.conf with
    # the WireGuard gateway address (which would make dnsmasq forward to itself).
    pre_wg_dns = get_dns_servers()

    # Ensure rathole binary is available before bringing WireGuard up.
    # Once wg-quick rewrites /etc/resolv.conf, DNS resolves via the WireGuard
    # gateway — which won't work until rathole is running (chicken-and-egg).
    with Status("[cyan]Preparing rathole binary…[/cyan]", console=console):
        try:
            rathole_mgr.ensure_rathole(
                console_print=lambda msg: console.print(f"  [dim]{msg}[/dim]")
            )
        except BeaconSystemError as exc:
            _abort(f"Tunnel setup failed: {exc}")

    # WireGuard
    with Status("[cyan]Configuring WireGuard…[/cyan]", console=console):
        try:
            # Tear down any stale WireGuard interfaces left over from a
            # previous run (e.g. Docker restart with shared network namespace).
            wg.teardown_all()
            wg.write_config(wg_interface, wg_config)
            wg.enable_ip_forwarding()
            wg.up(wg_interface)
        except BeaconSystemError as exc:
            wg.remove_config(wg_interface)
            # Clean up: delete the beacon from the portal since setup failed
            try:
                with _api(s) as api:
                    api.delete(beacon_id)
                warning(f"Beacon [bold]{name}[/bold] deleted from portal due to setup failure.")
            except Exception:
                pass
            # Give a helpful message if iptables is missing
            if not wg.is_iptables_available() or "iptables" in str(exc).lower():
                _abort(
                    "WireGuard setup failed: [bold]iptables[/bold] is not available or may not be "
                    "supported on this OS. Please install it or use the install script to deploy via Docker."
                )
            _abort(f"WireGuard setup failed: {exc}")

    step(f"WireGuard interface [bold]{wg_interface}[/bold] is up")

    # Rathole
    with Status("[cyan]Starting secure tunnel (rathole)…[/cyan]", console=console):
        try:
            pid = rathole_mgr.start(
                beacon_id=beacon_id,
                server=rathole_server,
                control_port=rathole_control_port,
                token=beacon_token,
                local_port=wg_port,
            )
        except BeaconSystemError as exc:
            # Roll back WireGuard
            try:
                wg.down(wg_interface)
            except Exception:
                pass
            _abort(f"Tunnel setup failed: {exc}")

    step(f"Rathole tunnel active → [bold]{rathole_server}:{rathole_public_port}[/bold] (pid {pid})")

    # DNS forwarder
    if dns_mgr.is_installed():
        with Status("[cyan]Starting DNS forwarder…[/cyan]", console=console):
            try:
                dns_mgr.start(beacon_id, gateway_ip, upstream=pre_wg_dns)
                step(f"DNS forwarder on [bold]{gateway_ip}:53[/bold]")
            except BeaconSystemError as exc:
                warning(f"DNS forwarder failed to start: {exc}")
    else:
        warning("dnsmasq not found - DNS forwarding disabled. Install dnsmasq and re-run.")

    # Persist local state
    state = LocalState.load()
    state.add(
        LocalBeacon(
            beacon_id=beacon_id,
            name=name,
            interface=wg_interface,
            wg_port=wg_port,
            rathole_config_path=str(rathole_mgr.RATHOLE_DIR / f"client-{beacon_id}.toml"),
            rathole_pid=pid,
            beacon_token=beacon_token,
            rathole_server=rathole_server,
            rathole_control_port=rathole_control_port,
            rathole_public_port=rathole_public_port,
            gateway_ip=gateway_ip,
        )
    )

    console.print()
    success(f"Beacon [bold]{name}[/bold] is running!")
    console.print(
        f"  [muted]ID:[/muted] [cyan]{beacon_id}[/cyan]\n"
        f"  [muted]Public endpoint:[/muted] [bold]{rathole_server}:{rathole_public_port}[/bold]\n"
        f"  [muted]Run[/muted] [accent]beacon status {beacon_id[:8]}[/accent] [muted]to check anytime.[/muted]"
    )

    # Add CIDRs to pentest scope
    if not skip_pentest:
        if create_pentest and not pentest_slug:
            _create_pentest_and_add_cidrs(
                s=s,
                org_id=s.org_id,
                beacon_id=beacon_id,
                beacon_name=name,
                cidrs=selected_cidrs,
            )
        else:
            _add_cidrs_to_pentest(
                s=s,
                org_id=s.org_id,
                beacon_id=beacon_id,
                cidrs=selected_cidrs,
                pentest_slug=pentest_slug,
                non_interactive=non_interactive,
                beacon_name=name,
            )


# list

@app.command(name="list")
def list_beacons(
    page: int = typer.Option(1, "--page", "-p", help="Page number"),
    per_page: int = typer.Option(20, "--per-page", help="Results per page"),
    status_filter: Optional[str] = typer.Option(
        None, "--status", "-s", help="Filter by status (active/inactive/faulty)"
    ),
) -> None:
    """List all beacons for your account."""
    s = _settings()
    _resolve_org(s)
    with Status("[cyan]Fetching beacons…[/cyan]", console=console):
        try:
            with _api(s) as api:
                data = api.list(page=page, per_page=per_page, status=status_filter)
        except BeaconAPIError as exc:
            _abort(str(exc))

    beacons = data.get("beacons", data.get("results", data)) if isinstance(data, dict) else data
    count = data.get("count", len(beacons)) if isinstance(data, dict) else len(beacons)

    print_beacon_table(beacons)

    if count > per_page:
        total_pages = (count + per_page - 1) // per_page
        info(f"Page {page}/{total_pages} - use [accent]--page[/accent] to navigate.")


# status

@app.command()
def status(
    beacon_id: str = typer.Argument(..., help="Beacon ID (or prefix)"),
) -> None:
    """Show details and local service status for a beacon."""
    s = _settings()
    _resolve_org(s)
    with Status("[cyan]Fetching beacon…[/cyan]", console=console):
        try:
            with _api(s) as api:
                data = api.get(beacon_id)
        except BeaconAPIError as exc:
            _abort(str(exc))

    state = LocalState.load()
    local = state.get(beacon_id)
    local_running = False

    if local:
        wg_up = wg.is_up(local.interface)
        rat_up = rathole_mgr.is_running(beacon_id)
        local_running = wg_up and rat_up
        if wg_up:
            step(f"WireGuard [bold]{local.interface}[/bold]: [success]up[/success]")
        else:
            step(f"WireGuard [bold]{local.interface}[/bold]: [error]down[/error]")

        if rat_up:
            step("Rathole tunnel: [success]running[/success]")
        else:
            step("Rathole tunnel: [error]stopped[/error]")
        console.print()

    print_beacon_detail(data, local_running=local_running)


# start

@app.command()
def start(
    beacon_id: str = typer.Argument(..., help="Beacon ID"),
) -> None:
    """Start an existing beacon."""
    state = LocalState.load()
    local = state.get(beacon_id)

    # Use local state when complete - no API credentials required
    if local and local.beacon_token and local.rathole_server and local.rathole_control_port:
        _settings_for_runtime()  # load .env for side effects only
        interface = local.interface
        wg_port = local.wg_port
        rathole_server: str = local.rathole_server
        rathole_control_port: int = local.rathole_control_port
        beacon_token: str = local.beacon_token
        gateway_ip: str = local.gateway_ip or "10.252.0.1"
        wg_config = ""  # config already on disk
        name = local.name
        use_api = False
    else:
        # API fallback - fetch fresh config (credentials required)
        s = _settings()
        _resolve_org(s, non_interactive=True)

        with Status("[cyan]Fetching beacon config…[/cyan]", console=console):
            try:
                with _api(s) as api:
                    data = api.get(beacon_id)
            except BeaconAPIError as exc:
                _abort(str(exc))

        interface = local.interface if local else f"wg{beacon_id}"
        wg_port = local.wg_port if local else find_free_udp_port()

        rathole_server = data.get("rathole_server")
        rathole_control_port = data.get("rathole_control_port")
        beacon_token = data.get("beacon_token")

        if not all([rathole_server, rathole_control_port, beacon_token]):
            _abort(
                "Incomplete rathole config returned by API. "
                "Try deleting and recreating the beacon."
            )

        wg_config = data.get("wireguard_config", "")
        name = data.get("name", beacon_id)
        gateway_ip = None  # derived after WG setup
        use_api = True

    # Ensure sudo credentials are cached before any spinners start.
    if wg._sudo_available():
        info("WireGuard requires [bold]sudo[/bold] to manage network interfaces. You may be prompted for your password.")
    try:
        wg.prompt_sudo()
    except BeaconSystemError as exc:
        _abort(str(exc))

    # Capture system DNS before wg-quick can overwrite /etc/resolv.conf.
    pre_wg_dns = get_dns_servers()

    # Ensure rathole binary is available before WireGuard rewrites DNS.
    with Status("[cyan]Preparing rathole binary…[/cyan]", console=console):
        try:
            rathole_mgr.ensure_rathole(
                console_print=lambda msg: console.print(f"  [dim]{msg}[/dim]")
            )
        except BeaconSystemError as exc:
            _abort(str(exc))

    with Status("[cyan]Starting WireGuard…[/cyan]", console=console):
        try:
            wg.teardown_all()
            if wg_config:
                wg_config = _inject_wg_listen_port(wg_config, wg_port)
                wg_config = _fix_wg_masquerade_iface(wg_config)
                wg.write_config(interface, wg_config)
            wg.enable_ip_forwarding()
            wg.up(interface)
        except BeaconSystemError as exc:
            _abort(str(exc))

    step(f"WireGuard [bold]{interface}[/bold] is up")

    with Status("[cyan]Starting rathole tunnel…[/cyan]", console=console):
        try:
            pid = rathole_mgr.start(
                beacon_id=beacon_id,
                server=rathole_server,
                control_port=rathole_control_port,
                token=beacon_token,
                local_port=wg_port,
            )
        except BeaconSystemError as exc:
            try:
                wg.down(interface)
            except Exception:
                pass
            _abort(str(exc))

    step(f"Rathole tunnel active (pid {pid})")

    # DNS forwarder
    if use_api:
        # Derive gateway IP from config
        if wg_config:
            gateway_ip = _get_wg_gateway_ip(wg_config)
        else:
            saved_conf = wg.WG_CONF_DIR / f"{interface}.conf"
            gateway_ip = _get_wg_gateway_ip(saved_conf.read_text()) if saved_conf.exists() else "10.252.0.1"
        # Backfill DNS on the API for beacons created before auto-DNS was introduced
        if not data.get("dns_server"):
            try:
                with _api(s) as api:
                    api.update(beacon_id, dns_server=gateway_ip)
            except BeaconAPIError:
                pass

    if dns_mgr.is_installed():
        try:
            dns_mgr.start(beacon_id, gateway_ip, upstream=pre_wg_dns)
            step(f"DNS forwarder on [bold]{gateway_ip}:53[/bold]")
        except BeaconSystemError as exc:
            warning(f"DNS forwarder failed to start: {exc}")

    # Persist local state
    if local:
        local.rathole_pid = pid
        if use_api:
            # Backfill runtime credentials so future starts skip the API
            local.beacon_token = beacon_token
            local.rathole_server = rathole_server
            local.rathole_control_port = rathole_control_port
            local.rathole_public_port = data.get("rathole_public_port", 0)
            local.gateway_ip = gateway_ip
        state.save()
    else:
        state.add(
            LocalBeacon(
                beacon_id=beacon_id,
                name=name,
                interface=interface,
                wg_port=wg_port,
                rathole_pid=pid,
                beacon_token=beacon_token,
                rathole_server=rathole_server,
                rathole_control_port=rathole_control_port,
                rathole_public_port=data.get("rathole_public_port", 0),
                gateway_ip=gateway_ip,
            )
        )

    success(f"Beacon [bold]{name}[/bold] started.")


# stop

@app.command()
def stop(
    beacon_id: str = typer.Argument(..., help="Beacon ID"),
) -> None:
    """Stop a running beacon."""
    state = LocalState.load()
    local = state.get(beacon_id)

    if not local:
        _abort(f"No local state found for beacon {beacon_id}. Is it running on this machine?")

    with Status("[cyan]Stopping services…[/cyan]", console=console):
        dns_mgr.stop(beacon_id)
        rathole_mgr.stop(beacon_id)
    step("Rathole tunnel and DNS forwarder stopped")

    with Status("[cyan]Bringing down WireGuard…[/cyan]", console=console):
        try:
            wg.down(local.interface)
        except BeaconSystemError as exc:
            warning(f"WireGuard down failed: {exc}")
    step(f"WireGuard [bold]{local.interface}[/bold] down")

    success(f"Beacon [bold]{local.name}[/bold] stopped.")


# restart

@app.command()
def restart(
    beacon_id: str = typer.Argument(..., help="Beacon ID"),
) -> None:
    """Restart a beacon."""
    state = LocalState.load()
    local = state.get(beacon_id)
    if local and rathole_mgr.is_running(beacon_id):
        with Status("[cyan]Stopping services…[/cyan]", console=console):
            dns_mgr.stop(beacon_id)
            rathole_mgr.stop(beacon_id)
            try:
                wg.down(local.interface)
            except BeaconSystemError:
                pass

    ctx = click.get_current_context()
    ctx.invoke(start, beacon_id=beacon_id)


# delete

@app.command()
def delete(
    beacon_id: str = typer.Argument(..., help="Beacon ID"),
    yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
) -> None:
    """Delete a beacon (stops local services and removes it from the portal)."""
    s = _settings()
    _resolve_org(s)

    if not yes:
        if not Confirm.ask(
            f"[warning]Delete beacon [bold]{beacon_id[:8]}…[/bold]? This action also deletes all associated assets, removing those assets from all testing. This cannot be undone.[/warning]",
            default=False,
        ):
            raise typer.Exit(0)

    # Stop local services if running
    state = LocalState.load()
    local = state.get(beacon_id)
    if local:
        dns_mgr.stop(beacon_id)
        if rathole_mgr.is_running(beacon_id):
            rathole_mgr.stop(beacon_id)
        try:
            wg.down(local.interface)
        except BeaconSystemError:
            pass
        wg.remove_config(local.interface)
        state.remove(beacon_id)

    with Status("[cyan]Deleting beacon…[/cyan]", console=console):
        try:
            with _api(s) as api:
                api.delete(beacon_id)
        except BeaconAPIError as exc:
            _abort(str(exc))

    success(f"Beacon [bold]{beacon_id[:8]}…[/bold] deleted.")


# update

@app.command()
def update(
    beacon_id: str = typer.Argument(..., help="Beacon ID"),
    name: Optional[str] = typer.Option(None, "--name", "-n", help="New name"),
    cidrs: Optional[str] = typer.Option(None, "--cidrs", "-c", help="New allowed CIDRs"),
) -> None:
    """Update beacon configuration on the portal."""
    s = _settings()
    _resolve_org(s)
    payload: dict = {}
    if name:
        err = validate_name_input(name)
        if err is not True:
            _abort(err)
        payload["name"] = name
    if cidrs:
        err = validate_cidrs_input(cidrs)
        if err is not True:
            _abort(err)
        payload["allowed_cidrs"] = [c.strip() for c in cidrs.split(",")]

    if not payload:
        warning("Nothing to update. Use --name or --cidrs.")
        raise typer.Exit(0)

    with Status("[cyan]Updating beacon…[/cyan]", console=console):
        try:
            with _api(s) as api:
                data = api.update(beacon_id, **payload)
        except BeaconAPIError as exc:
            _abort(str(exc))

    success(f"Beacon [bold]{data.get('name', beacon_id[:8])}[/bold] updated.")
    print_beacon_detail(data)


# health

@app.command()
def health(
    interval: int = typer.Option(
        300, "--interval", "-i", envvar="ETHIACK_BEACON_HEALTH_INTERVAL",
        help="Reporting interval in seconds (default: 300)"
    ),
    once: bool = typer.Option(
        False, "--once", help="Report once and exit (for cron/systemd-timer use)"
    ),
) -> None:
    """
    Continuously report health status for all beacons running on this machine.

    Runs forever by default (interval seconds between reports). Use --once
    for one-shot execution via cron or systemd timer.
    """
    s = _settings_for_runtime()  # beacon_token from local state - no API key/secret needed

    def _report_all() -> None:
        state = LocalState.load()
        if not state.beacons:
            info("No local beacons registered. Nothing to report.")
            return

        for beacon_id, local in state.beacons.items():
            wg_status = wg.get_status(local.interface)
            rat_up = rathole_mgr.is_running(beacon_id)
            rat_connected = rathole_mgr.is_connected(beacon_id, window_seconds=interval * 2)
            dns_up = dns_mgr.is_running(beacon_id)

            if wg_status["active"] and rat_connected:
                beacon_status = "active"
            else:
                beacon_status = "faulty"

            details = {
                "wireguard": wg_status,
                "rathole_running": rat_up,
                "rathole_connected": rat_connected,
                "dns_running": dns_up,
                "interface": local.interface,
            }

            if not local.beacon_token:
                warning(f"No beacon token for {local.name} - skipping health report.")
                continue

            try:
                report_health(s.api_url, beacon_id, beacon_status, local.beacon_token, details)
                step(f"[bold]{local.name}[/bold] → {status_badge(beacon_status)}")
            except BeaconAPIError as exc:
                warning(f"Health report failed for {local.name}: {exc}")

    if once:
        _report_all()
        return

    def _shutdown(signum: int, frame: object) -> None:
        """Tear down all local beacons on SIGTERM (e.g. docker compose down)."""
        info("Shutting down…")
        state = LocalState.load()
        for bid, local in state.beacons.items():
            dns_mgr.stop(bid)
            rathole_mgr.stop(bid)
            try:
                wg.down(local.interface)
            except Exception:
                pass
        sys.exit(0)

    signal.signal(signal.SIGTERM, _shutdown)

    info(f"Reporting health every {interval}s. Press Ctrl+C to stop.")
    try:
        while True:
            _report_all()
            time.sleep(interval)
    except KeyboardInterrupt:
        _shutdown(signal.SIGINT, None)


# test
'''
# TODO @0xacb deactivated for now
@app.command(name="test")
def test_connection(
    beacon_id: str = typer.Argument(..., help="Beacon ID"),
    target: str = typer.Argument(
        ..., help="Internal IP, hostname, or URL to test (e.g. 192.168.1.1 or http://host/path)"
    ),
) -> None:
    """Test connectivity from the beacon to an internal target."""
    s = _settings()
    _resolve_org(s)

    with Status(f"[cyan]Testing connection to [bold]{target}[/bold]…[/cyan]", console=console):
        try:
            with _api(s) as api:
                result = api.test_connection(beacon_id, target)
        except BeaconAPIError as exc:
            _abort(str(exc))

    reachable = result.get("reachable", False)
    latency = result.get("latency_ms")
    detail = result.get("detail", "")

    if reachable:
        latency_str = f" ({latency}ms)" if latency is not None else ""
        success(f"[bold]{target}[/bold] is reachable{latency_str}")
    else:
        error(f"[bold]{target}[/bold] is NOT reachable")

    if detail:
        info(detail)
'''

# Helpers

def _all_provided(*values: Optional[str]) -> bool:
    return all(v is not None for v in values)


# Entry point

if __name__ == "__main__":
    app()
