"""
Fi-Wi **relay** to a hub host: same bootstrap **over SSH** (Fedora → Pi) or **locally** (on the Pi).

* ``local=False`` (default): ``ssh user@host bash -lc '…'`` using ``FIWI_REMOTE_SCRIPT`` for ``cd``.
* ``local=True``: ``bash -lc '…'`` on this machine using :func:`fiwi.paths.base_dir` as the repo root.

The CLI picks **local** automatically when the configured ``user@host`` matches this machine
(:meth:`ssh_target_points_here`); use ``--remote`` to force SSH, ``--local`` to force local.
"""

from __future__ import annotations

import os
import shlex
import subprocess
import sys
from dataclasses import dataclass, replace


def _ssh_target_user_host(ssh_target: str) -> tuple[str, str]:
    if "@" not in ssh_target:
        raise ValueError("expected user@host")
    u, h = ssh_target.rsplit("@", 1)
    u, h = u.strip(), h.strip()
    if not u or not h:
        raise ValueError("expected user@host")
    return u, h


def _local_ipv4_addresses() -> set[str]:
    import socket

    out: set[str] = {"127.0.0.1"}
    try:
        for nm in (socket.gethostname(), socket.getfqdn()):
            try:
                _, _, ips = socket.gethostbyname_ex(nm)
                out.update(ips)
            except OSError:
                pass
    except OSError:
        pass
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.settimeout(0.2)
        try:
            s.connect(("8.8.8.8", 80))
            out.add(s.getsockname()[0])
        except OSError:
            pass
        finally:
            s.close()
    except OSError:
        pass
    return out


def ssh_target_points_here(ssh_target: str) -> bool:
    """
    True if ``ssh_target`` refers to **this** OS user and host (hostname, FQDN short name, or IPv4).

    Used to skip SSH when the configured hub is the machine running Fi-Wi.
    """
    import getpass
    import socket

    try:
        cfg_user, cfg_host = _ssh_target_user_host(ssh_target)
    except ValueError:
        return False
    if getpass.getuser() != cfg_user:
        return False
    h = cfg_host.lower()
    if h in ("localhost", "127.0.0.1", "::1", "[::1]"):
        return True
    hn = socket.gethostname().lower()
    if h == hn:
        return True
    fq = socket.getfqdn().lower()
    if h == fq:
        return True
    if "." in fq and h == fq.split(".", 1)[0]:
        return True
    local_ips = _local_ipv4_addresses()
    if h in local_ips:
        return True
    try:
        for ai in socket.getaddrinfo(cfg_host, None, socket.AF_INET, socket.SOCK_STREAM):
            if ai[4][0] in local_ips:
                return True
    except OSError:
        pass
    return False


def _default_label(ssh_target: str) -> str:
    host = ssh_target.split("@", 1)[-1] if "@" in ssh_target else ssh_target
    return host.replace(".", "_")


def _bash_cd_remote_repo(remote_script: str) -> str:
    """
    ``cd`` to the parent of ``FIWI_REMOTE_SCRIPT`` on the remote (``bash -lc``).

    ``~/`` becomes ``$HOME/`` so tilde expands reliably.
    """
    s = (remote_script or "").strip()
    if "/" not in s:
        return 'echo "Invalid FIWI_REMOTE_SCRIPT"; exit 1'
    parent = s.rsplit("/", 1)[0]
    if parent.startswith("~/"):
        return f'cd "$HOME/{parent[2:]}"'
    return f"cd {shlex.quote(parent)}"


def suggested_remote_venv_python(remote_script: str) -> str:
    """
    Typical ``remote_python`` after :meth:`FiWiRelay.setup`: ``env/bin/python3`` next to ``fiwi.py``.

    Uses the same parent directory as ``FIWI_REMOTE_SCRIPT`` (repo root on the hub).
    """
    s = (remote_script or "").strip()
    if "/" not in s:
        return "~/env/bin/python3"
    parent = s.rsplit("/", 1)[0]
    return f"{parent}/env/bin/python3"


def _remote_readiness_bash_inner(cfg) -> str:
    """Single ``bash -lc`` script: markers for :func:`parse_remote_readiness_stdout`."""
    from fiwi.ssh import SshNode

    script = (cfg.script or "").strip()
    py = SshNode.remote_path_bash_word(cfg.python)
    cd = _bash_cd_remote_repo(cfg.script)
    if not script:
        return (
            "echo __FIWI_CD__=skip; echo __FIWI_SCRIPT__=missing; echo __FIWI_VENV__=no; "
            "echo __FIWI_BRAINSTEM__=fail; echo __FIWI_BRAINSTEM_VENV__=na; "
            "echo __FIWI_HOSTNAME__=; echo __FIWI_UNAME__=; echo __FIWI_PY__="
        )
    return (
        "set +e; "
        + cd
        + " && { "
        + 'echo __FIWI_HOSTNAME__="$(hostname 2>/dev/null)"; '
        + 'echo __FIWI_UNAME__="$(uname -sm 2>/dev/null)"; '
        + f"echo __FIWI_SCRIPT__=$(test -f {script} && echo ok || echo missing); "
        + "echo __FIWI_VENV__=$(test -d env && echo yes || echo no); "
        + f"echo __FIWI_BRAINSTEM__=$({py} -c 'import brainstem' 2>/dev/null && echo ok || echo fail); "
        + "echo __FIWI_BRAINSTEM_VENV__=$(if test -x env/bin/python3; then "
        + "env/bin/python3 -c 'import brainstem' 2>/dev/null && echo ok || echo fail; else echo na; fi); "
        + f"PY_CMD={py}; "
        + "echo __FIWI_PY__=$($PY_CMD -c 'import sys; print(sys.executable)'); "
        + "echo __FIWI_CD__=ok; "
        + "} || { echo __FIWI_CD__=fail; echo __FIWI_HOSTNAME__=; echo __FIWI_UNAME__=; "
        + "echo __FIWI_SCRIPT__=missing; echo __FIWI_VENV__=no; echo __FIWI_BRAINSTEM__=fail; "
        + "echo __FIWI_BRAINSTEM_VENV__=na; echo __FIWI_PY__=; exit 0; }"
    )


def parse_remote_readiness_stdout(stdout: str) -> dict[str, str]:
    """
    Parse ``__FIWI_*__=…`` lines emitted by :func:`probe_remote_hub_readiness`.

    Unknown or duplicate keys: last line wins.
    """
    out: dict[str, str] = {}
    for ln in (stdout or "").splitlines():
        ln = ln.strip()
        if "=" not in ln or not ln.startswith("__FIWI_"):
            continue
        key, _, rest = ln.partition("=")
        if key in (
            "__FIWI_CD__",
            "__FIWI_SCRIPT__",
            "__FIWI_VENV__",
            "__FIWI_BRAINSTEM__",
            "__FIWI_BRAINSTEM_VENV__",
            "__FIWI_HOSTNAME__",
            "__FIWI_UNAME__",
            "__FIWI_PY__",
        ):
            out[key] = rest.strip()
    return out


@dataclass(frozen=True)
class RemoteReadiness:
    """
    Result of :func:`probe_remote_hub_readiness` — whether a hub host can run Fi-Wi (BrainStem, paths).

    Use :meth:`suggests_fiwi_relay_setup` to decide if ``python -m fiwi.fiwi_relay`` (or
    :meth:`FiWiRelay.setup`) is likely needed: configured ``FIWI_REMOTE_PYTHON`` cannot import
    ``brainstem`` after ``cd`` to the repo from ``FIWI_REMOTE_SCRIPT``.
    """

    ssh_connected: bool
    timed_out: bool
    ssh_exit_code: int
    cd_ok: bool
    script_present: bool
    venv_present: bool
    brainstem_import_ok: bool
    brainstem_ok_in_venv: bool
    hostname: str
    uname: str
    remote_python_executable: str
    raw_stdout: str
    raw_stderr: str

    @property
    def suggests_remote_python_venv(self) -> bool:
        """
        True when ``brainstem`` imports in ``env/bin/python3`` but not in ``FIWI_REMOTE_PYTHON``.

        Fix: set ``remote_python`` in INI (see :func:`suggested_remote_venv_python`).
        """
        if not self.ssh_connected or self.timed_out:
            return False
        if not self.cd_ok or not self.script_present:
            return False
        if self.brainstem_import_ok:
            return False
        return self.brainstem_ok_in_venv

    @property
    def suggests_fiwi_relay_setup(self) -> bool:
        """
        True when the hub still needs a venv / pip install (``brainstem`` missing even from
        ``env/bin/python3``). If :meth:`suggests_remote_python_venv` is true, relay setup already
        ran — update ``remote_python`` instead.
        """
        if not self.ssh_connected or self.timed_out:
            return False
        if not self.cd_ok or not self.script_present:
            return False
        if self.brainstem_import_ok:
            return False
        if self.brainstem_ok_in_venv:
            return False
        return True


def probe_remote_hub_readiness(ssh_target: str, *, timeout: float = 45.0) -> RemoteReadiness:
    """
    One SSH session: hostname, repo ``cd``, ``fiwi.py`` present, ``env/`` exists, ``import brainstem``
    using merged ``FIWI_REMOTE_PYTHON``.

    Requires prior :func:`fiwi.paths.configure` (and ``FIWI_CONFIG`` if you use INI profiles).
    """
    from fiwi.ssh import SshNodeConfig

    cfg = SshNodeConfig.load()
    inner = _remote_readiness_bash_inner(cfg)
    argv = [cfg.ssh_bin, *cfg.ssh_extra_argv, ssh_target, "bash", "-lc", inner]
    empty = RemoteReadiness(
        ssh_connected=False,
        timed_out=False,
        ssh_exit_code=-1,
        cd_ok=False,
        script_present=False,
        venv_present=False,
        brainstem_import_ok=False,
        brainstem_ok_in_venv=False,
        hostname="",
        uname="",
        remote_python_executable="",
        raw_stdout="",
        raw_stderr="",
    )
    try:
        proc = subprocess.run(
            argv,
            capture_output=True,
            text=True,
            timeout=timeout,
            stdin=subprocess.DEVNULL,
            errors="replace",
        )
    except subprocess.TimeoutExpired:
        return replace(
            empty,
            timed_out=True,
            raw_stderr="ssh timed out",
        )
    except OSError as exc:
        return replace(
            empty,
            raw_stderr=str(exc),
        )

    out = proc.stdout or ""
    err = proc.stderr or ""
    rc = proc.returncode if proc.returncode is not None else 1
    d = parse_remote_readiness_stdout(out)
    ssh_connected = rc == 0
    cd_mark = d.get("__FIWI_CD__", "")
    cd_ok = cd_mark == "ok"
    script_present = d.get("__FIWI_SCRIPT__", "") == "ok"
    venv_present = d.get("__FIWI_VENV__", "") == "yes"
    brainstem_import_ok = d.get("__FIWI_BRAINSTEM__", "") == "ok"
    bv = d.get("__FIWI_BRAINSTEM_VENV__", "")
    brainstem_ok_in_venv = bv == "ok"
    return RemoteReadiness(
        ssh_connected=ssh_connected,
        timed_out=False,
        ssh_exit_code=rc,
        cd_ok=cd_ok,
        script_present=script_present,
        venv_present=venv_present,
        brainstem_import_ok=brainstem_import_ok,
        brainstem_ok_in_venv=brainstem_ok_in_venv,
        hostname=d.get("__FIWI_HOSTNAME__", ""),
        uname=d.get("__FIWI_UNAME__", ""),
        remote_python_executable=d.get("__FIWI_PY__", ""),
        raw_stdout=out,
        raw_stderr=err,
    )


@dataclass
class FiWiRelay:
    """
    Relay to a Fi-Wi **hub host** (e.g. ``name="rpi5"``, ``ssh_target="user@host"``).

    Use :meth:`from_config` after :func:`fiwi.paths.configure` so ``FIWI_*`` and INI are merged.
    """

    name: str
    ssh_target: str

    @staticmethod
    def ssh_target_points_here(ssh_target: str) -> bool:
        """See :func:`ssh_target_points_here`."""
        return ssh_target_points_here(ssh_target)

    def __post_init__(self) -> None:
        t = (self.ssh_target or "").strip()
        if not t or "@" not in t:
            raise ValueError("ssh_target must be non-empty user@host")
        object.__setattr__(self, "ssh_target", t)
        n = (self.name or "").strip() or _default_label(t)
        object.__setattr__(self, "name", n)

    @classmethod
    def from_config(
        cls,
        app_dir: str,
        *,
        ssh_target: str | None = None,
        name: str | None = None,
    ) -> FiWiRelay:
        import fiwi.paths as paths_mod

        paths_mod.configure(os.path.abspath(app_dir))
        from fiwi.ssh import SshNodeConfig

        raw = (SshNodeConfig.load().calibrate_remotes or "").strip()
        if not raw:
            raise ValueError(
                "No remote hub configured: set [remote_hubs] hosts or FIWI_REMOTE_HUBS / "
                "FIWI_CALIBRATE_REMOTES (and optional [remote_hubs] label)."
            )
        first = raw.split(",")[0].strip()
        tgt = (ssh_target or first).strip()
        nm = (name or (os.environ.get("FIWI_REMOTE_HUB_LABEL") or "").strip() or _default_label(tgt)).strip()
        return cls(name=nm, ssh_target=tgt)

    @classmethod
    def for_local_machine(cls, app_dir: str, *, name: str | None = None) -> FiWiRelay:
        """
        Hub **is** this machine: no ``[remote_hubs]`` required. Use with :meth:`install_dependencies` / :meth:`setup` and ``local=True``.

        ``ssh_target`` is set to ``user@hostname`` for logging only; it is not used when ``local=True``.
        """
        import getpass
        import socket

        import fiwi.paths as paths_mod

        paths_mod.configure(os.path.abspath(app_dir))
        u = getpass.getuser()
        host = socket.gethostname()
        nm = (name or (os.environ.get("FIWI_REMOTE_HUB_LABEL") or "").strip() or host).strip()
        return cls(name=nm, ssh_target=f"{u}@{host}")

    @classmethod
    def each_from_config(cls, app_dir: str) -> list[FiWiRelay]:
        """One object per merged ``user@host`` (INI ``label`` names the first; extras get ``label_2``, …)."""
        import fiwi.paths as paths_mod

        paths_mod.configure(os.path.abspath(app_dir))
        from fiwi.ssh import SshNodeConfig

        raw = (SshNodeConfig.load().calibrate_remotes or "").strip()
        if not raw:
            return []
        default_label = (os.environ.get("FIWI_REMOTE_HUB_LABEL") or "").strip()
        out: list[FiWiRelay] = []
        j = 0
        for part in raw.split(","):
            h = part.strip()
            if not h:
                continue
            if j == 0:
                nm = default_label or _default_label(h)
            elif default_label:
                nm = f"{default_label}_{j + 1}"
            else:
                nm = _default_label(h)
            j += 1
            out.append(cls(name=nm, ssh_target=h))
        return out

    def _ssh_argv(self, inner: str) -> list[str]:
        from fiwi.ssh import SshNodeConfig

        cfg = SshNodeConfig.load()
        return [cfg.ssh_bin, *cfg.ssh_extra_argv, self.ssh_target, "bash", "-lc", inner]

    def install_dependencies(
        self,
        *,
        timeout: float = 600.0,
        dry_run: bool = False,
        local: bool = False,
    ) -> int:
        """
        Ensure ``env/`` venv and ``pip install -r requirements.txt`` under the Fi-Wi repo.

        * ``local=False``: run the script on ``ssh_target`` (Fedora → Pi).
        * ``local=True``: run on **this** host; repo root is :func:`fiwi.paths.base_dir` (Pi interactive shell).

        Returns shell exit code (0 on success).
        """
        verify = './env/bin/python3 -c \'import brainstem; print("brainstem OK")\''
        if local:
            import fiwi.paths as paths_mod

            base = paths_mod.base_dir()
            cd = f"cd {shlex.quote(base)}"
            inner = (
                "set -euo pipefail; "
                f"{cd}; "
                "test -f requirements.txt || { echo 'requirements.txt missing in repo root'; exit 1; }; "
                "test -f fiwi.py || { echo 'fiwi.py missing in repo root'; exit 1; }; "
                "if [ ! -d env ]; then python3 -m venv env; fi; "
                "./env/bin/python3 -m pip install -U pip; "
                "./env/bin/python3 -m pip install -r requirements.txt; "
                f"{verify}"
            )
            argv = ["bash", "-lc", inner]
            print(
                f"[{self.name}] local (this machine): pip install in {base}\n"
                f"  bash -lc '…'",
                flush=True,
            )
        else:
            from fiwi.ssh import SshNodeConfig

            cfg = SshNodeConfig.load()
            script = (cfg.script or "").strip()
            cd = _bash_cd_remote_repo(script)
            inner = (
                "set -euo pipefail; "
                f"{cd}; "
                "test -f requirements.txt || { echo 'requirements.txt missing in repo root'; exit 1; }; "
                "test -f fiwi.py || { echo 'fiwi.py missing in repo root'; exit 1; }; "
                "if [ ! -d env ]; then python3 -m venv env; fi; "
                "./env/bin/python3 -m pip install -U pip; "
                "./env/bin/python3 -m pip install -r requirements.txt; "
                f"{verify}"
            )
            argv = self._ssh_argv(inner)
            print(
                f'[{self.name}] ssh {self.ssh_target}: pip install (repo from FIWI_REMOTE_SCRIPT)\n  '
                f"{' '.join(shlex.quote(a) for a in argv[:1])} … bash -lc '…'",
                flush=True,
            )
        if dry_run:
            print("  (dry-run: not executing)", flush=True)
            return 0
        try:
            proc = subprocess.run(
                argv,
                stdin=subprocess.DEVNULL,
                timeout=timeout,
            )
        except subprocess.TimeoutExpired:
            print(f"[{self.name}] timed out after {timeout}s", file=sys.stderr, flush=True)
            return 124
        except OSError as exc:
            print(f"[{self.name}] {exc}", file=sys.stderr, flush=True)
            return 1
        code = proc.returncode if proc.returncode is not None else 1
        if code == 0 and not dry_run and not local:
            from fiwi.ssh import SshNodeConfig

            hint = suggested_remote_venv_python(SshNodeConfig.load().script)
            print(
                f"[{self.name}] Next: set remote_python in [remote_ssh] to {hint!r} on the "
                f"workstation INI so SSH uses the venv (brainstem is not on system python3).",
                flush=True,
            )
        return code

    install_depends = install_dependencies

    def setup(self, **kwargs) -> int:
        """Bootstrap this remote hub (same as :meth:`install_dependencies`)."""
        return self.install_dependencies(**kwargs)
