"""``hermes gateway enroll`` — enroll a self-hosted gateway with a relay connector.

The connector⇄gateway channel is authenticated (the gateway may be
customer-managed and internet-exposed). This command is the gateway half of the
zero-touch enrollment in the connector repo's
``docs/connector-gateway-auth-design.md``:

  1. Resolve a fresh Nous Portal access token from the existing login
     (``~/.hermes/auth.json``) — the same path ``hermes dashboard register``
     uses (``resolve_nous_access_token``). This proves *which Nous org (tenant)*
     the caller owns; the connector derives the authoritative tenant from it via
     ``GET /api/oauth/account`` (never from anything the gateway asserts).
  2. POST ``{enrollmentToken, gatewayId}`` to the connector's ``/relay/enroll``
     with that token in the ``Authorization`` header, over TLS.
  3. The connector verifies the enrollment token (signature + single-use +
     tenant match), mints a per-gateway secret, get-or-creates the per-tenant
     delivery key, and returns both ONCE.
  4. Persist ``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` /
     ``GATEWAY_RELAY_DELIVERY_KEY`` (+ ``GATEWAY_RELAY_URL`` if supplied) into
     ``~/.hermes/.env``. The per-gateway secret authenticates the WS upgrade;
     the per-tenant delivery key verifies signed inbound deliveries.

Managed/hosted installs do NOT self-enroll: the orchestrator (NAS) mints the
secret directly and stamps it into the container env, so this command refuses to
run under ``is_managed()`` (mirrors ``dashboard register``).

EXPERIMENTAL: the relay auth scheme may change without a deprecation cycle until
≥2 Class-1 platforms validate the contract.
"""

from __future__ import annotations

import json
import os
import socket
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional


def _default_gateway_id() -> str:
    """A stable-ish default gateway instance id: ``<hostname>-<pid-free slug>``.

    The gatewayId identifies this enrolled instance for kill-switch granularity
    (the connector indexes its secret verify list by it). Default to the host
    name so a human can recognize it; overridable via ``--gateway-id``.
    """
    host = ""
    try:
        host = socket.gethostname().strip()
    except Exception:
        host = ""
    return f"gw-{host or 'hermes'}"


def _resolve_connector_url(override: Optional[str]) -> Optional[str]:
    """Resolve the connector base URL (no trailing slash) for enrollment.

    Precedence: explicit ``--connector-url`` flag > ``GATEWAY_RELAY_URL`` env >
    ``gateway.relay_url`` in config.yaml. The relay URL is a ``ws(s)://`` dial
    target; enrollment is an ``http(s)://`` POST to the same host, so we map the
    scheme. Returns None when nothing is configured (the user must supply one).
    """
    raw = (override or os.environ.get("GATEWAY_RELAY_URL", "")).strip()
    if not raw:
        try:
            from gateway.run import _load_gateway_config  # late import to avoid cycle

            cfg = (_load_gateway_config().get("gateway") or {})
            raw = str(cfg.get("relay_url", "") or "").strip()
        except Exception:
            raw = ""
    if not raw:
        return None
    raw = raw.rstrip("/")
    # The relay dial URL is ws(s)://…/relay; enrollment posts to http(s)://…/relay/enroll.
    if raw.startswith("ws://"):
        raw = "http://" + raw[len("ws://"):]
    elif raw.startswith("wss://"):
        raw = "https://" + raw[len("wss://"):]
    # Strip a trailing /relay path segment if the user pasted the dial URL.
    if raw.endswith("/relay"):
        raw = raw[: -len("/relay")]
    return raw


def _resolve_identity_token() -> str:
    """Resolve the caller-identity bearer token (generic-OIDC or Nous Portal).

    Delegates to the canonical resolver in ``gateway.relay`` so the enroll CLI and
    the runtime self-provision path share ONE implementation (generic OAuth2
    client-credentials when ``gateway.idp.token_url`` is set — the air-gapped /
    self-hosted-IdP path; otherwise Nous Portal). Raises RuntimeError on failure.
    """
    from gateway.relay import _resolve_relay_identity_token

    return _resolve_relay_identity_token()



def _post_enroll(
    *,
    connector_base_url: str,
    access_token: str,
    enrollment_token: str,
    gateway_id: str,
    timeout: float = 15.0,
) -> dict:
    """POST to the connector's ``/relay/enroll`` and return the JSON body.

    Raises RuntimeError with a user-facing message on any non-2xx / transport
    failure. The connector returns ``{secret, deliveryKey, tenant, gatewayId}``
    on success, ``{error}`` at 400/401/403.
    """
    url = f"{connector_base_url.rstrip('/')}/relay/enroll"
    data = json.dumps({"enrollmentToken": enrollment_token, "gatewayId": gateway_id}).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=data,
        method="POST",
        headers={
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            payload = json.loads(resp.read().decode())
    except urllib.error.HTTPError as exc:
        detail = ""
        try:
            detail = (json.loads(exc.read().decode()) or {}).get("error", "")
        except Exception:
            pass
        if exc.code == 401:
            raise RuntimeError(
                "Connector rejected the caller identity (401). Your Nous Portal "
                "token could not be verified — try `hermes auth add nous` and retry."
            ) from exc
        if exc.code == 403:
            raise RuntimeError(
                detail
                or "Enrollment token invalid, expired, already used, or tenant mismatch (403)."
            ) from exc
        raise RuntimeError(
            f"Connector returned HTTP {exc.code}" + (f": {detail}" if detail else "")
        ) from exc
    except urllib.error.URLError as exc:
        raise RuntimeError(
            f"Could not reach the connector at {connector_base_url}: {exc.reason}"
        ) from exc

    if not isinstance(payload, dict) or not payload.get("secret"):
        raise RuntimeError("Connector returned an unexpected response (no secret).")
    return payload


def cmd_gateway_enroll(args) -> None:
    """Enroll this gateway with a relay connector; persist the auth creds to .env."""
    from hermes_cli.auth import AuthError
    from hermes_cli.config import is_managed, save_env_value

    # Managed installs get GATEWAY_RELAY_* stamped in by the orchestrator (NAS
    # mints the secret directly per the design's managed shape). Self-enrolling
    # from inside such a container is a mistake — and save_env_value refuses to
    # write anyway.
    if is_managed():
        print(
            "✗ `hermes gateway enroll` is not available in a managed/hosted install.\n"
            "  The relay gateway secret is provisioned by the hosting platform."
        )
        sys.exit(1)

    enrollment_token = (getattr(args, "token", None) or os.environ.get("GATEWAY_RELAY_ENROLL_TOKEN", "")).strip()
    if not enrollment_token:
        print(
            "✗ No enrollment token. Pass --token <token> (or set "
            "GATEWAY_RELAY_ENROLL_TOKEN).\n"
            "  The connector mints this single-use token when your tenant's route "
            "is provisioned; it is delivered with your gateway config."
        )
        sys.exit(1)

    connector_base_url = _resolve_connector_url(getattr(args, "connector_url", None))
    if not connector_base_url:
        print(
            "✗ No connector URL. Pass --connector-url <url> (or set GATEWAY_RELAY_URL "
            "/ gateway.relay_url in config.yaml)."
        )
        sys.exit(1)

    gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip()

    # 1. Resolve the caller-identity token (the tenant-proving identity). Generic
    #    OIDC client-credentials when an IdP token endpoint is configured (air-
    #    gapped / self-hosted-IdP, NO Nous Portal); otherwise the Nous Portal token.
    try:
        access_token = _resolve_identity_token()
    except AuthError as exc:
        if getattr(exc, "relogin_required", False):
            print("✗ You're not logged into Nous Portal.")
            print("  Run `hermes setup` (or `hermes auth add nous`) first, then retry.")
        else:
            print(f"✗ Could not resolve a Nous Portal access token: {exc}")
        sys.exit(1)
    except Exception as exc:
        print(f"✗ Could not resolve a caller-identity token: {exc}")
        sys.exit(1)

    # 2-3. Redeem the enrollment token at the connector.
    try:
        result = _post_enroll(
            connector_base_url=connector_base_url,
            access_token=access_token,
            enrollment_token=enrollment_token,
            gateway_id=gateway_id,
        )
    except RuntimeError as exc:
        print(f"✗ Enrollment failed: {exc}")
        sys.exit(1)

    secret = str(result.get("secret") or "")
    delivery_key = str(result.get("deliveryKey") or "")
    tenant = str(result.get("tenant") or "")
    resolved_gateway_id = str(result.get("gatewayId") or gateway_id)

    # 4. Persist the creds idempotently. The secret + delivery key are sensitive;
    #    save_env_value writes them to ~/.hermes/.env (0600 dir) and never logs.
    to_write = {
        "GATEWAY_RELAY_ID": resolved_gateway_id,
        "GATEWAY_RELAY_SECRET": secret,
        "GATEWAY_RELAY_DELIVERY_KEY": delivery_key,
    }
    # Persist the connector URL too (as the ws(s):// dial target) when supplied
    # explicitly, so the runtime can dial without re-specifying it.
    explicit_url = (getattr(args, "connector_url", None) or "").strip()
    if explicit_url:
        to_write["GATEWAY_RELAY_URL"] = explicit_url.rstrip("/")

    # Phase 5 §5.2: persist the wake URL so self_provision_relay forwards it to
    # the connector (which pokes it to wake this gateway when buffered work
    # arrives while it's idle). Optional — omitted ⇒ the connector can't wake it,
    # but the gateway still drains on its next reconnect.
    explicit_wake_url = (getattr(args, "wake_url", None) or "").strip()
    if explicit_wake_url:
        to_write["GATEWAY_RELAY_WAKE_URL"] = explicit_wake_url.rstrip("/")

    for key, value in to_write.items():
        if not value:
            continue
        try:
            save_env_value(key, value)
        except Exception as exc:
            print(f"✗ Failed to write {key} to .env: {exc}")
            sys.exit(1)

    from hermes_cli.config import get_env_path

    print(f'✓ Enrolled gateway "{resolved_gateway_id}"' + (f" for tenant {tenant}" if tenant else ""))
    print()
    print(f"  Wrote to {get_env_path()}:")
    print(f"    GATEWAY_RELAY_ID={resolved_gateway_id}")
    print("    GATEWAY_RELAY_SECRET=<hidden>")
    print("    GATEWAY_RELAY_DELIVERY_KEY=<hidden>")
    if explicit_url:
        print(f"    GATEWAY_RELAY_URL={explicit_url.rstrip('/')}")
    if explicit_wake_url:
        print(f"    GATEWAY_RELAY_WAKE_URL={explicit_wake_url.rstrip('/')}")
    print()
    # GATEWAY_RELAY_URL / GATEWAY_RELAY_WAKE_URL are process-global deployment
    # stamps (agent/secret_scope.py): a multiplexed gateway resolves them from
    # the PROCESS environment only, never from a secondary profile's .env
    # (which is loaded into an isolated secret scope, not exported). The .env
    # write above works for a single-profile gateway and for the profile the
    # process is launched under (load_hermes_dotenv exports that .env), so
    # warn rather than refuse — but don't let a secondary-profile enroll claim
    # a config that will silently never activate. Emitted BEFORE the generic
    # restart line so the two don't contradict each other.
    warned_secondary = False
    if explicit_url or explicit_wake_url:
        warned_secondary = _warn_if_secondary_multiplex_profile()
    if not warned_secondary:
        print(
            "  The gateway now authenticates its relay WS upgrade with the per-gateway\n"
            "  secret and verifies signed inbound deliveries with the tenant delivery\n"
            "  key. Restart the gateway to pick up the new env."
        )


def _warn_if_secondary_multiplex_profile() -> bool:
    """Warn when relay routing stamps were written to a secondary profile's
    .env that a multiplexed gateway will never read them from. Returns True
    when the warning fired (the caller suppresses the generic restart text).

    The topology decision is owned by the DEFAULT root, not the active
    profile home: ``multiplex_profiles`` normally lives in
    ``<default_root>/config.yaml`` (or the GATEWAY_MULTIPLEX_PROFILES env
    override), and the secondary check is the resolved-path relationship to
    ``<default_root>/profiles/`` — mirroring the multiplexer-conflict guard
    in hermes_cli/gateway.py. Best-effort: any failure to determine the
    topology stays silent (the credential write itself succeeded).
    """
    try:
        from hermes_constants import get_default_hermes_root
        from hermes_cli.config import get_hermes_home

        default_root = Path(get_default_hermes_root()).resolve()
        home = Path(get_hermes_home()).resolve()
        try:
            home.relative_to(default_root / "profiles")
        except ValueError:
            return False  # default profile or custom layout — not a secondary

        # Multiplex flag precedence mirrors gateway.config: recognized env
        # override wins, else the DEFAULT root's config.yaml (raw read — the
        # active profile's load_gateway_config() is the wrong owner AND runs
        # the full enablement pass, including the relay-exclusive sweep's own
        # log output, which has no place in enroll output).
        from gateway.config import _env_multiplex_profiles_override
        env_multiplex = _env_multiplex_profiles_override()
        if env_multiplex is False:
            return False
        if env_multiplex is not True:
            cfg_path = default_root / "config.yaml"
            if not cfg_path.exists():
                return False
            from hermes_cli.config import read_user_config_raw
            cfg = read_user_config_raw(cfg_path) or {}
            if not bool(
                cfg.get("multiplex_profiles")
                or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
            ):
                return False

        print(
            "  ⚠ This profile is a SECONDARY profile of a multiplexed gateway.\n"
            "    GATEWAY_RELAY_URL / GATEWAY_RELAY_WAKE_URL are process-level\n"
            "    deployment settings: the gateway reads them from the process\n"
            "    environment (or the default profile's .env), not from this\n"
            "    profile's .env. Set them in the environment the gateway process\n"
            "    is launched with, or enroll from the default profile. The\n"
            "    relay credentials written above are valid either way."
        )
        return True
    except Exception:
        return False
