#!/usr/bin/env python3
# tunnel_agent.py
#
# Host-side agent (repo-local config):
#   Reads config from:
#     <repo>/.config/net.env     (REMOTERF_ADDR, REMOTERF_CA_CERT, optional mTLS)
#     <repo>/.config/host.env    (HOST_ID, optional HOST_MASTER_TOKEN)
#     <repo>/.config/devices.env (DEVICE_<n>_... schema)
#
#   Connects to main server via HostTunnel bidi stream, announces devices, executes RPC locally.
#
# Logging:
#   REMOTERF_LOG_LEVEL=DEBUG|INFO|WARNING|ERROR   (default INFO)

from __future__ import annotations

import os
import re
import sys
import time
import queue
import grpc
import random
import secrets
import logging
import threading
import traceback

from pathlib import Path
from dataclasses import dataclass
from typing import Dict, Any, Optional, Iterable, List, Tuple, NoReturn

from ..common.grpc import grpc_host_pb2 as host_pb2
from ..common.grpc import grpc_host_pb2_grpc as host_pb2_grpc

# -------------------------
# Logging
# -------------------------

def _setup_logging() -> logging.Logger:
    level_s = (os.getenv("REMOTERF_LOG_LEVEL", "") or os.getenv("HOSTRF_LOG_LEVEL", "") or "ERROR").upper().strip()
    level = getattr(logging, level_s, logging.INFO)

    root = logging.getLogger()
    if not root.handlers:
        logging.basicConfig(
            level=level,
            format="%(asctime)s.%(msecs)03d %(levelname)s [%(threadName)s] %(name)s: %(message)s",
            datefmt="%H:%M:%S",
        )
    else:
        root.setLevel(level)

    return logging.getLogger("hostrf.tunnel_agent")


LOG = _setup_logging()

# -------------------------
# Fatal config helper
# -------------------------

def _die_config(net_env: Path, msg: str, *, code: int = 2) -> NoReturn:
    LOG.error("Config error: %s", msg)
    LOG.error("Config file: %s", str(net_env))
    LOG.error("Fix:")
    LOG.error("  hostrf -c -a <host:port>")
    LOG.error("  hostrf -c -a --show")
    raise SystemExit(code)

# -------------------------
# Generic Argument message import
# -------------------------

try:
    generic_pb2 = host_pb2.grpc__pb2  # generated alias inside grpc_host_pb2.py
except Exception:
    from ..common.grpc import grpc_pb2 as generic_pb2  # fallback


# -------------------------
# Project imports (host-side)
# -------------------------

from .rpc_manager import rpc_manager  # type: ignore

try:
    from .device_manager import get_all_devices_str  # type: ignore
except Exception as e:
    get_all_devices_str = None
    LOG.debug("device_manager.get_all_devices_str unavailable: %s", e)

# -------------------------
# Misc helpers
# -------------------------

def _now_ms() -> int:
    return int(time.time() * 1000)

def _now_s() -> float:
    return time.time()

# -------------------------
# repo-local config paths
# -------------------------

def _repo_root() -> Path:
    """
    Try to find the repo root by locating the 'src' directory in parents and returning its parent.
    Fallback: walk up a few levels from this file.
    """
    p = Path(__file__).resolve()
    for parent in p.parents:
        if parent.name == "src":
            return parent.parent
    # fallback (best-effort)
    if len(p.parents) >= 4:
        return p.parents[3]
    return p.parent

def _cfg_dir() -> Path:
    return _repo_root() / ".config"

def _net_env_path() -> Path:
    return _cfg_dir() / "net.env"

def _host_env_path() -> Path:
    return _cfg_dir() / "host.env"

def _devices_env_path() -> Path:
    return _cfg_dir() / "devices.env"

def _resolve_path(base_env_path: Path, p: str) -> str:
    """
    If p is relative, resolve it relative to the directory containing base_env_path.
    """
    if not p:
        return ""
    pp = Path(p).expanduser()
    if pp.is_absolute():
        return str(pp)
    return str((base_env_path.parent / pp).resolve())

# -------------------------
# .env parsing (no deps)
# -------------------------

_ENV_LINE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$")

def _strip_quotes(v: str) -> str:
    v = v.strip()
    if len(v) >= 2 and ((v[0] == v[-1] == '"') or (v[0] == v[-1] == "'")):
        return v[1:-1]
    return v

def load_env_file(path: str) -> Dict[str, str]:
    out: Dict[str, str] = {}
    if not path or not os.path.exists(path):
        return out
    with open(path, "r", encoding="utf-8") as f:
        for raw in f.read().splitlines():
            line = raw.strip()
            if not line or line.startswith("#"):
                continue
            m = _ENV_LINE.match(line)
            if not m:
                continue
            k = m.group(1).strip()
            v = _strip_quotes(m.group(2))
            out[k] = v
    return out

# -------------------------
# Device parsing
# -------------------------

_DEVICE_KEY_RE = re.compile(r"^DEVICE_(\d+)_(.+)$", re.IGNORECASE)

def _parse_devices_from_env(env: Dict[str, str]) -> Dict[int, Dict[str, str]]:
    """
    Parse devices.env written by hostrf_cli.py.

    Expected keys:
      DEVICE_<gid>_TYPE=pluto
      DEVICE_<gid>_NAME=pluto_aaa
      DEVICE_<gid>_IDENT_KIND=iio_serial
      DEVICE_<gid>_IDENT=<serial>

    Returns:
      { local_id: {"type","name","ident_kind","ident","device_id","serial","kind","label"} }
    """
    recs: Dict[int, Dict[str, str]] = {}

    for k, v in env.items():
        m = _DEVICE_KEY_RE.match(k.strip())
        if not m:
            continue
        gid = int(m.group(1))
        field = m.group(2).upper().strip()
        recs.setdefault(gid, {})[field] = str(v).strip()

    out: Dict[int, Dict[str, str]] = {}
    for gid, r in recs.items():
        dtype = r.get("TYPE", "").strip()
        name = r.get("NAME", "").strip()
        ident_kind = r.get("IDENT_KIND", "").strip()
        ident = r.get("IDENT", "").strip()

        # Require minimal identity
        if not dtype or not ident_kind or not ident:
            continue

        # Stable device_id convention
        dt = dtype.lower()
        ik = ident_kind.lower()
        if dt == "pluto" and ik == "iio_serial":
            device_id = f"pluto:{ident}"
        else:
            device_id = f"{dt}:{ik}:{ident}"

        serial = ident if ik in ("iio_serial", "serial") else ""
        out[gid] = {
            "type": dtype,
            "name": name,
            "ident_kind": ident_kind,
            "ident": ident,
            "device_id": device_id,
            "serial": serial,
            "kind": dtype,               # map to proto DeviceInfo.kind
            "label": name or f"Device {gid}",
        }

    return out

# -------------------------
# Argument helpers
# -------------------------

def _argument_bool(x: bool) -> Any:
    a = generic_pb2.Argument()
    a.bool_value = bool(x)
    return a

def _argument_string(s: str) -> Any:
    a = generic_pb2.Argument()
    a.string_value = str(s)
    return a

def _coerce_argument(v: Any) -> Any:
    if v is None:
        return _argument_string("None")

    if isinstance(v, generic_pb2.Argument):
        return v

    if hasattr(v, "SerializeToString"):
        try:
            a = generic_pb2.Argument()
            a.ParseFromString(v.SerializeToString())
            return a
        except Exception:
            # Not the same message type; fall through
            pass

    if isinstance(v, bool):
        return _argument_bool(v)
    if isinstance(v, int):
        a = generic_pb2.Argument()
        a.int64_value = int(v)
        return a
    if isinstance(v, float):
        a = generic_pb2.Argument()
        a.float_value = float(v)
        return a

    return _argument_string(str(v))

def _coerce_argument_map(m: Any) -> Dict[str, Any]:
    out: Dict[str, Any] = {}
    if m is None:
        return out
    try:
        for k in m:
            out[str(k)] = _coerce_argument(m[k])
    except Exception:
        for k, v in dict(m).items():
            out[str(k)] = _coerce_argument(v)
    return out

# -------------------------
# Agent config + agent
# -------------------------

@dataclass
class AgentConfig:
    # derived from net.env (REMOTERF_ADDR)
    server_host: str
    server_port: int

    # repo-local envs
    net_env_path: str
    host_env_path: str
    devices_env_path: str

    # TLS paths (derived from net.env)
    ca_pem_path: Optional[str] = None
    client_cert_pem_path: Optional[str] = None
    client_key_pem_path: Optional[str] = None

    heartbeat_interval_sec: float = 10.0
    reconnect_min_delay_sec: float = 1.0
    reconnect_max_delay_sec: float = 30.0
    outbound_max: int = 2048

    version: str = "host-agent/0.2"


class HostTunnelAgent:
    def __init__(self, cfg: AgentConfig) -> None:
        self.cfg = cfg
        self.log = logging.getLogger("hostrf.tunnel_agent")

        self._stop = threading.Event()
        self._out_q: "queue.Queue[Optional[host_pb2.HostFrame]]" = queue.Queue(maxsize=cfg.outbound_max)

        self._cancel_flags: Dict[str, threading.Event] = {}
        self._cancel_lock = threading.Lock()

        self._host_id: str = ""
        self._master_token: str = "SuperCoolTokenForIan"  # default; do not log value
        self._devices: Dict[int, Dict[str, str]] = {}

    def stop(self) -> None:
        self.log.info("Stop requested.")
        self._stop.set()
        try:
            self._out_q.put_nowait(None)
        except Exception as e:
            self.log.debug("Stop enqueue sentinel failed: %s", e)

    def _enqueue(self, frame: host_pb2.HostFrame, *, timeout: float = 2.0) -> bool:
        try:
            self._out_q.put(frame, timeout=timeout)
            return True
        except queue.Full:
            which = frame.WhichOneof("msg")
            self.log.warning("Outbound queue full (max=%d). Dropping frame type=%s", self.cfg.outbound_max, which)
            return False

    def _register_cancel(self, req_id: str) -> threading.Event:
        ev = threading.Event()
        with self._cancel_lock:
            self._cancel_flags[req_id] = ev
        return ev

    def _consume_cancel(self, req_id: str) -> None:
        with self._cancel_lock:
            self._cancel_flags.pop(req_id, None)

    def _set_cancel(self, req_id: str) -> None:
        with self._cancel_lock:
            ev = self._cancel_flags.get(req_id)
        if ev is not None:
            ev.set()

    # -------- host inventory (host.env + devices.env) --------

    def _reload_from_env(self) -> None:
        host_env = load_env_file(self.cfg.host_env_path)
        dev_env = load_env_file(self.cfg.devices_env_path)

        # HOST_ID
        host_id = host_env.get("HOST_ID", "").strip()
        if not host_id:
            host_id = os.getenv("HOST_ID", "").strip()
        if not host_id:
            # best-effort stable-ish fallback if user didn't set one
            host_id = f"host-{secrets.token_hex(4)}"
            self.log.warning("HOST_ID not set; using generated host_id=%s (set HOST_ID to make stable).", host_id)
        self._host_id = host_id

        # HOST_MASTER_TOKEN (optional)
        mt = (
            host_env.get("HOST_MASTER_TOKEN", "").strip()
            or host_env.get("MASTER_TOKEN", "").strip()
            or os.getenv("HOST_MASTER_TOKEN", "").strip()
            or os.getenv("MASTER_TOKEN", "").strip()
        )
        if mt:
            self._master_token = mt

        token_state = "set" if bool(mt) else "default"

        # devices (record-style schema from hostrf_cli.py)
        merged = dict(dev_env)
        # allow device fields living in host.env too (optional)
        for k, v in host_env.items():
            if k not in merged:
                merged[k] = v

        self._devices = _parse_devices_from_env(merged)

        self.log.info(
            "Inventory reloaded: host_id=%s devices=%d master_token=%s host_env=%s devices_env=%s",
            self._host_id,
            len(self._devices),
            token_state,
            self.cfg.host_env_path,
            self.cfg.devices_env_path,
        )

    # -------- frames --------

    def _hello_frame(self) -> host_pb2.HostFrame:
        return host_pb2.HostFrame(
            hello=host_pb2.HostHello(host_id=self._host_id, version=self.cfg.version)
        )

    def _heartbeat_frame(self) -> host_pb2.HostFrame:
        return host_pb2.HostFrame(
            heartbeat=host_pb2.Heartbeat(unix_ms=_now_ms())
        )

    def _device_announce_frame(self) -> host_pb2.HostFrame:
        # Optional dynamic labels from device_manager (if present)
        dyn_labels: Dict[int, str] = {}
        if callable(get_all_devices_str):
            try:
                dyn_labels = dict(get_all_devices_str())  # type: ignore
            except Exception as e:
                self.log.debug("get_all_devices_str() failed: %s", e)
                dyn_labels = {}

        infos: List[host_pb2.DeviceInfo] = []
        for local_id in sorted(self._devices.keys()):
            d = self._devices[local_id]

            label = (
                d.get("label", "").strip()
                or dyn_labels.get(local_id, "")
                or f"Device {local_id}"
            )

            infos.append(
                host_pb2.DeviceInfo(
                    local_id=int(local_id),
                    label=str(label),
                    device_id=str(d.get("device_id", "")),
                    serial=str(d.get("serial", "")),
                    kind=str(d.get("kind", "")),
                )
            )

        self.log.info("Announcing %d device(s) to server.", len(infos))

        return host_pb2.HostFrame(
            device_announce=host_pb2.DeviceAnnounce(
                devices=infos,
                unix_ms=_now_ms(),
                full_snapshot=True,
            )
        )

    # -------- meta --------

    def _new_meta_message(self):
        """
        Create a meta message instance in a defensive way without assuming the exact proto name.
        If your proto defines a specific type (e.g. HostMeta), this will find it.
        """
        for name in ("HostMeta", "Meta", "MetaPayload"):
            cls = getattr(host_pb2, name, None)
            if cls is not None:
                try:
                    return cls()
                except Exception:
                    continue
        return None

    def _collect_meta(self, req: host_pb2.MetaRequest):
        """
        Best-effort meta payload. If your proto doesn't define the expected meta message, return None.
        This avoids crashes on meta requests while still logging what's happening.
        """
        meta = self._new_meta_message()
        if meta is None:
            self.log.warning("MetaRequest received but no compatible meta message type found in host_pb2.")
            return None

        # Best-effort field fills; skip if field doesn't exist
        def _set(field: str, value: Any) -> None:
            try:
                setattr(meta, field, value)
            except Exception:
                pass

        _set("host_id", self._host_id)
        _set("unix_ms", _now_ms())
        _set("version", self.cfg.version)

        # You can add more fields here if your proto supports them.
        return meta

    def _handle_meta_request(self, req: host_pb2.MetaRequest) -> None:
        req_id = str(req.req_id)
        cancel_ev = self._register_cancel(req_id)

        try:
            self.log.info("MetaRequest req_id=%s", req_id)

            if cancel_ev.is_set():
                self.log.info("MetaRequest req_id=%s canceled before handling.", req_id)
                return

            self._reload_from_env()

            meta = self._collect_meta(req)

            frame = host_pb2.HostFrame(
                meta_response=host_pb2.MetaResponse(
                    req_id=req_id,
                    ok=True,
                    error="",
                    meta=meta,  # may be None depending on proto; adjust if your proto disallows None
                )
            )
            self._enqueue(frame)

        except Exception as e:
            self.log.exception("MetaRequest req_id=%s failed: %s", req_id, e)
            frame = host_pb2.HostFrame(
                meta_response=host_pb2.MetaResponse(
                    req_id=req_id,
                    ok=False,
                    error=str(e),
                )
            )
            self._enqueue(frame)

        finally:
            self._consume_cancel(req_id)

    # -------- channel --------

    def _make_channel(self) -> grpc.Channel:
        addr = f"{self.cfg.server_host}:{int(self.cfg.server_port)}"

        options = [
            ("grpc.max_send_message_length", 100 * 1024 * 1024),
            ("grpc.max_receive_message_length", 100 * 1024 * 1024),
            ("grpc.keepalive_time_ms", 30_000),
            ("grpc.keepalive_timeout_ms", 10_000),
            ("grpc.http2.max_pings_without_data", 0),
            ("grpc.keepalive_permit_without_calls", 1),
        ]

        if not self.cfg.ca_pem_path:
            self.log.info("Opening INSECURE channel to %s", addr)
            return grpc.insecure_channel(addr, options=options)

        ca_path = Path(self.cfg.ca_pem_path).expanduser().resolve()
        with ca_path.open("rb") as f:
            trusted_certs = f.read()

        if self.cfg.client_cert_pem_path and self.cfg.client_key_pem_path:
            cert_path = Path(self.cfg.client_cert_pem_path).expanduser().resolve()
            key_path = Path(self.cfg.client_key_pem_path).expanduser().resolve()
            with cert_path.open("rb") as f:
                client_cert = f.read()
            with key_path.open("rb") as f:
                client_key = f.read()

            credentials = grpc.ssl_channel_credentials(
                root_certificates=trusted_certs,
                private_key=client_key,
                certificate_chain=client_cert,
            )
            mtls = True
        else:
            credentials = grpc.ssl_channel_credentials(root_certificates=trusted_certs)
            mtls = False

        self.log.info("Opening TLS channel to %s (CA=%s, mTLS=%s)", addr, str(ca_path), "yes" if mtls else "no")
        return grpc.secure_channel(addr, credentials, options=options)

    # -------- rpc execution --------

    def _override_token_for_local_device(self, args_map: Any, local_device_id: int) -> None:
        try:
            forced = f"{self._master_token}_{int(local_device_id)}_force"
            args_map["a"].CopyFrom(_argument_string(forced))
        except Exception as e:
            # Not fatal, but do not hide it.
            self.log.warning("Failed to override token for local_device_id=%d: %s", int(local_device_id), e)

    def _handle_rpc_request(self, req: host_pb2.RpcRequest) -> None:
        req_id = str(req.req_id)
        _ = self._register_cancel(req_id)

        t0 = _now_s()
        try:
            greq = req.request
            args_map = greq.args

            self.log.info(
                "RpcRequest req_id=%s local_device_id=%d fn=%s",
                req_id,
                int(req.local_device_id),
                str(greq.function_name),
            )

            self._override_token_for_local_device(args_map, int(req.local_device_id))

            results_any = rpc_manager.run_rpc(function_name=greq.function_name, args=args_map)

            gresp = generic_pb2.GenericRPCResponse()
            results = _coerce_argument_map(results_any)
            for k, v in results.items():
                gresp.results[str(k)].CopyFrom(v)

            out = host_pb2.HostFrame(
                rpc_response=host_pb2.RpcResponse(
                    req_id=req_id,
                    ok=True,
                    error="",
                    response=gresp,
                )
            )
            self._enqueue(out)

            dt_ms = int((_now_s() - t0) * 1000.0)
            self.log.info("RpcResponse req_id=%s ok=true dt_ms=%d", req_id, dt_ms)

        except Exception as e:
            tb = traceback.format_exc(limit=20)
            self.log.exception("RpcRequest req_id=%s FAILED: %s", req_id, e)

            gresp = generic_pb2.GenericRPCResponse()
            gresp.results["Ok"].CopyFrom(_argument_bool(False))
            gresp.results["Error"].CopyFrom(_argument_string(f"{e}\n{tb}"))

            out = host_pb2.HostFrame(
                rpc_response=host_pb2.RpcResponse(
                    req_id=req_id,
                    ok=False,
                    error=str(e),
                    response=gresp,
                )
            )
            self._enqueue(out)

        finally:
            self._consume_cancel(req_id)

    # -------- outbound stream --------

    def _outbound_generator(self) -> Iterable[host_pb2.HostFrame]:
        self.log.debug("Outbound generator started.")
        while not self._stop.is_set():
            try:
                item = self._out_q.get(timeout=0.5)
            except queue.Empty:
                continue
            except Exception as e:
                self.log.exception("Outbound queue get failed: %s", e)
                continue

            if item is None:
                self.log.debug("Outbound generator got sentinel; exiting.")
                return

            yield item

        self.log.debug("Outbound generator exiting due to stop flag.")

    # -------- main loop --------

    def run_forever(self) -> None:
        delay = self.cfg.reconnect_min_delay_sec

        self.log.info("HostTunnelAgent starting: target=%s:%d version=%s",
                      self.cfg.server_host, int(self.cfg.server_port), self.cfg.version)

        while not self._stop.is_set():
            hb_stop: Optional[threading.Event] = None
            channel: Optional[grpc.Channel] = None

            try:
                self._reload_from_env()

                self.log.info("Connecting to server %s:%d ...", self.cfg.server_host, int(self.cfg.server_port))
                channel = self._make_channel()
                stub = host_pb2_grpc.HostTunnelStub(channel)

                # Initial frames
                self._enqueue(self._hello_frame())
                self._enqueue(self._device_announce_frame())

                hb_stop = threading.Event()

                def hb_loop() -> None:
                    try:
                        self.log.debug("Heartbeat loop started (interval=%.1fs).", self.cfg.heartbeat_interval_sec)
                        while not hb_stop.is_set() and not self._stop.is_set():
                            ok = self._enqueue(self._heartbeat_frame())
                            if not ok:
                                self.log.debug("Heartbeat enqueue failed (queue full).")
                            time.sleep(self.cfg.heartbeat_interval_sec)
                    except Exception as e:
                        self.log.exception("Heartbeat loop crashed: %s", e)
                    finally:
                        self.log.debug("Heartbeat loop exiting.")

                threading.Thread(target=hb_loop, daemon=True, name="hb_loop").start()

                # Start stream
                stream = stub.Connect(self._outbound_generator())
                delay = self.cfg.reconnect_min_delay_sec
                self.log.info("HostTunnel stream established.")

                for frame in stream:
                    which = frame.WhichOneof("msg")
                    if which == "rpc_request":
                        threading.Thread(
                            target=self._handle_rpc_request,
                            args=(frame.rpc_request,),
                            daemon=True,
                            name=f"rpc_{frame.rpc_request.req_id}",
                        ).start()
                    elif which == "meta_request":
                        threading.Thread(
                            target=self._handle_meta_request,
                            args=(frame.meta_request,),
                            daemon=True,
                            name=f"meta_{frame.meta_request.req_id}",
                        ).start()
                    elif which == "cancel":
                        rid = str(frame.cancel.req_id)
                        self.log.info("Cancel req_id=%s", rid)
                        self._set_cancel(rid)
                    elif which == "heartbeat":
                        # server heartbeat; optionally log at DEBUG only
                        self.log.debug("Server heartbeat.")
                    else:
                        self.log.debug("Received frame type=%s", which)

                self.log.warning("HostTunnel stream ended (server closed or connection dropped).")

            except grpc.RpcError as e:
                code = None
                details = ""
                try:
                    code = e.code()
                except Exception:
                    code = None
                try:
                    details = e.details() or ""
                except Exception:
                    details = ""
                self.log.warning("gRPC error: code=%s details=%s", str(code), str(details))

            except Exception as e:
                self.log.exception("Unexpected error in run loop: %s", e)

            finally:
                if hb_stop is not None:
                    hb_stop.set()
                if channel is not None:
                    try:
                        channel.close()
                    except Exception as e:
                        self.log.debug("Channel close failed: %s", e)

            if self._stop.is_set():
                break

            # Backoff + jitter (prevents reconnect synchronization)
            sleep_s = delay + random.uniform(0.0, min(0.5, delay * 0.2))
            self.log.info("Reconnecting in %.2fs ...", sleep_s)
            time.sleep(sleep_s)
            delay = min(delay * 1.7, self.cfg.reconnect_max_delay_sec)

        self.log.info("HostTunnelAgent exiting.")

# -------------------------
# Thread helper
# -------------------------

def start_host_tunnel_agent_thread(cfg: AgentConfig) -> Tuple[HostTunnelAgent, threading.Thread]:
    agent = HostTunnelAgent(cfg)
    th = threading.Thread(target=agent.run_forever, daemon=True, name="host_tunnel_agent")
    th.start()
    return agent, th

# -------------------------
# Config parsing
# -------------------------

def _parse_addr(addr: str) -> Tuple[str, int]:
    a = (addr or "").strip()
    if not a or ":" not in a:
        raise ValueError("REMOTERF_ADDR must be like 'host:port'")
    host, port_s = a.rsplit(":", 1)
    return host.strip(), int(port_s.strip())

# -------------------------
# Main
# -------------------------

def main() -> None:
    LOG.info("Starting host tunnel agent main().")

    net_env = _net_env_path()
    host_env = _host_env_path()
    dev_env = _devices_env_path()

    net_kv = load_env_file(str(net_env))
    addr = (net_kv.get("REMOTERF_ADDR", "").strip() or os.getenv("REMOTERF_ADDR", "").strip())
    if not addr:
        _die_config(net_env, "REMOTERF_ADDR is not set.")

    try:
        server_host, server_port = _parse_addr(addr)
    except Exception as e:
        _die_config(net_env, f"Invalid REMOTERF_ADDR={addr!r} ({e}).")

    ca_path = _resolve_path(net_env, net_kv.get("REMOTERF_CA_CERT", "").strip())
    client_cert = _resolve_path(net_env, net_kv.get("REMOTERF_CLIENT_CERT", "").strip())
    client_key = _resolve_path(net_env, net_kv.get("REMOTERF_CLIENT_KEY", "").strip())

    cfg = AgentConfig(
        server_host=server_host,
        server_port=server_port,
        net_env_path=str(net_env),
        host_env_path=str(host_env),
        devices_env_path=str(dev_env),
        version=os.getenv("HOST_VERSION", "host-agent/dev"),
        ca_pem_path=ca_path or None,
        client_cert_pem_path=client_cert or None,
        client_key_pem_path=client_key or None,
    )

    LOG.info("Config: addr=%s:%d tls=%s net_env=%s",
             cfg.server_host, int(cfg.server_port),
             "yes" if cfg.ca_pem_path else "no",
             cfg.net_env_path)

    agent = HostTunnelAgent(cfg)
    try:
        agent.run_forever()
    except KeyboardInterrupt:
        LOG.info("KeyboardInterrupt; stopping agent.")
        agent.stop()
    except Exception as e:
        # If main crashes, DO NOT be silent.
        LOG.exception("Fatal error in main(): %s", e)
        raise

if __name__ == "__main__":
    main()