"""sshj.discover — ssh config parsing + lazy rc/history discovery
(leaf).

B2 step 3: moved verbatim from sshj_cli.py (single-file era) — no
behavior change. The Discovery thread takes its Checker by
constructor argument, so this module imports nothing from the
package.
"""

import glob
import os
import re
import shlex
import subprocess
import threading
import time

# ssh flags that take an argument (for command-line parsing). Boolean flags
# (-N -n -q -v -Y -X -T -t -4 -6 -a -A -g -f -G …) are NOT listed: they must
# not swallow the following token.
SSH_FLAG_ARGS = ("-l", "-p", "-i", "-o", "-F", "-E", "-e", "-c", "-J",
                 "-L", "-R", "-D", "-W", "-M", "-S", "-I", "-s")

# how many trailing history lines the lazy scan considers (H1: bounded via
# tail_lines, so a multi-GB HISTFILE never loads into memory)
HISTORY_SCAN_TAIL = 6000


def config_files():
    """Every ssh config file in play (deduped, order preserved)."""
    files = []

    def add(p):
        p = os.path.expanduser(p)
        if p not in files and os.path.isfile(p):
            files.append(p)

    for env in ("SSHJ_CONFIG", "SSH_CONFIG"):
        v = os.environ.get(env)
        if v:
            add(v)
    add(os.path.expanduser("~/.ssh/config"))
    add("/etc/ssh/ssh_config")
    for g in sorted(glob.glob("/etc/ssh/ssh_config.d/*.conf")):
        add(g)
    # Include directives (depth-limited)
    depth = 0
    while depth < 3:
        depth += 1
        known = len(files)
        for f in files:
            try:
                with open(f) as fh:
                    text = fh.read()
            except OSError:
                continue
            for raw in text.splitlines():
                line = raw.strip()
                if not line or line.startswith("#"):
                    continue
                k, _, v = (line.partition("=") if "=" in line
                          else line.partition(" "))
                if k.strip().lower() != "include" or not v.strip():
                    continue
                for pat in v.split():
                    for p in sorted(glob.glob(os.path.expanduser(pat))):
                        add(p)
        if len(files) == known:
            break
    return files


def parse_config_file(path):
    """Parse one ssh_config file -> list of {patterns, opts, from} entries (OSError -> [])."""
    entries, cur = [], None
    try:
        with open(path) as f:
            lines = f.read().splitlines()
    except OSError:
        return []
    for raw in lines:
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if "=" in line:
            key, _, val = line.partition("=")
        else:
            key, _, val = line.partition(" ")
        key, val = key.strip().lower(), val.strip()
        if not val:
            continue
        if key == "host":
            if cur:
                entries.append(cur)
            cur = {"patterns": val.split(), "opts": {}}
        elif cur is not None:
            cur["opts"][key] = val
    if cur:
        entries.append(cur)
    return entries


def parse_configs():
    """All entries across every configured ssh_config file."""
    entries = []
    for f in config_files():
        for e in parse_config_file(f):
            e["from"] = f
            entries.append(e)
    return entries


def tail_lines(path, n):
    """Last up-to-`n` lines of `path`, with bounded memory (H1).

    Reads a 64 KiB window from the end of the file and widens it (4x,
    capped at 8 MiB) until it holds more than `n` complete lines or the
    whole file is covered — so a multi-GB shell history is never read
    into memory. A window that starts mid-line drops that partial first
    line (which is why the check is `> n`, not `>= n`). Returns [] on
    any OSError, or fewer than `n` lines if the file is short (or the
    cap was hit by pathologically long lines).
    """
    lines = []
    try:
        with open(path, "rb") as f:
            f.seek(0, os.SEEK_END)
            size = f.tell()
            window = min(size, 65536)
            while True:
                start = size - window
                f.seek(start)
                text = f.read(window).decode("utf-8", "replace")
                lines = text.splitlines()
                if start == 0 or len(lines) > n:
                    return lines[-n:]
                new_window = min(window * 4, 8 * 1024 * 1024, size)
                if new_window == window:  # cap reached: return what we have
                    return lines[-n:]
                window = new_window
    except OSError:
        return []
    return lines[-n:]


def candidate_pool(entries):
    """Concrete hostnames to expand wildcards against: config HostNames + known_hosts."""
    c = set()
    for e in entries:
        if "hostname" in e["opts"]:
            c.add(e["opts"]["hostname"])
    try:
        with open(os.path.expanduser("~/.ssh/known_hosts")) as f:
            for ln in f:
                if ln.startswith("#"):
                    continue
                parts = ln.split()
                if not parts or parts[0].startswith("|"):
                    continue
                for x in parts[0].split(","):
                    x = x.strip()
                    if x.startswith("["):
                        x = x.strip("[]").split(":")[0]
                    else:
                        x = x.split(":")[0]
                    # not a host: hex fingerprints, emails (GSSAPI), empty
                    if (not x or "@" in x
                            or re.fullmatch(r"[\da-f]+", x)):
                        continue
                    c.add(x)
    except OSError:
        pass
    try:
        out = subprocess.run(
            ["tailscale", "status", "--json"],
            capture_output=True, text=True, timeout=3).stdout
        for m in re.finditer(r'"Name"\s*:\s*"([^"]+)"', out):
            c.add(m.group(1))
    except Exception:
        pass
    return c


# opts that make a Host block actually route a connection (used to decide
# whether a wildcard pattern like the system's `Host *` is worth expanding)
ROUTING_OPTS = ("hostname", "user", "port", "proxyjump", "proxycommand",
                "identityfile")


def expand_hosts(entries):
    """Expand wildcard Host patterns against the candidate pool (routing-opts only); dedupe by alias, first wins."""
    pool = candidate_pool(entries)
    out = []
    for e in entries:
        for pat in e["patterns"]:
            if "?" in pat or "*" in pat:
                # only expand wildcards that carry routing info, else the
                # system /etc/ssh/ssh_config `Host *` matches every name
                if not any(o in e["opts"] for o in ROUTING_OPTS):
                    continue
                rx = re.compile(
                    "^" + re.escape(pat).replace("\\?", ".").replace("\\*", ".*") + "$")
                for cand in sorted(pool):
                    if rx.match(cand):
                        out.append({"alias": cand, "opts": e["opts"],
                                    "from": e.get("from", "")})
            else:
                out.append({"alias": pat, "opts": e["opts"],
                            "from": e.get("from", "")})
    seen, uniq = set(), []
    for h in out:
        if h["alias"] not in seen:
            seen.add(h["alias"])
            uniq.append(h)
    return uniq


def resolve(alias, opts):
    """ssh -G a host -> {user, hostname, port, timeout}; falls back to the entry's own opts."""
    info = {}
    try:
        r = subprocess.run(["ssh", "-G", "--", alias],
                           capture_output=True, text=True, timeout=3)
        for ln in r.stdout.splitlines():
            k, _, v = ln.partition(" ")
            info[k.strip().lower()] = v.strip()
    except Exception:
        pass
    return {
        "user": info.get("user", opts.get("user", "")),
        "hostname": info.get("hostname", opts.get("hostname", "")),
        "port": info.get("port", opts.get("port", "22")),
        "timeout": info.get("connecttimeout", "0"),
    }


def ssh_target_from_line(line):
    """Parse an ssh command line -> (user, host, port) or None. Ignores the
    remainder after the target (remote command); callers check for our own
    probe's 'true' separately via the returned target index."""
    s = line.strip()
    try:
        toks = shlex.split(s)
    except ValueError:
        return None
    if not toks or os.path.basename(toks[0]) != "ssh":
        return None
    i, port = 1, ""
    while i < len(toks):
        t = toks[i]
        if t == "ssh":
            break
        if t == "-p" and i + 1 < len(toks):
            port = toks[i + 1]
            i += 2
            continue
        if t.startswith("-"):
            if t in SSH_FLAG_ARGS and i + 1 < len(toks):
                i += 2
            else:
                i += 1
        else:
            break
    if i >= len(toks):
        return None
    target = toks[i]
    user, _, host = target.rpartition("@")
    if user == "":
        user = ""
    else:
        user = user
    if host.count(":") == 1 and host.split(":", 1)[1].isdigit():
        host, _, p2 = host.partition(":")
        port = port or p2
    if not host:
        return None
    # remote command immediately after the target?
    j = i + 1
    while j < len(toks):
        t = toks[j]
        if t.startswith("-"):
            j += 2 if (t in SSH_FLAG_ARGS and j + 1 < len(toks)) else 1
        else:
            break
    rest = toks[j] if j < len(toks) else ""
    return user, host, port, rest


def make_host(user, host, port, src, src_path=""):
    """Build a host dict for a lazily discovered raw target."""
    info = {}
    try:
        r = subprocess.run(["ssh", "-G", "--", host],
                           capture_output=True, text=True, timeout=3)
        for ln in r.stdout.splitlines():
            k, _, v = ln.partition(" ")
            info[k.strip().lower()] = v.strip()
    except Exception:
        pass
    u = user or info.get("user", "")
    hn = info.get("hostname", host)
    p = port or ""
    if not p or p == "22":
        cfgp = info.get("port", "22")
        if not port and cfgp not in ("", "22"):
            p = cfgp
    target = hn + (f":{p}" if p and p not in ("", "22") else "")
    if u:
        target = f"{u}@{target}"
    return {"alias": host, "user": u, "hostname": hn,
            "port": p or "22", "timeout": info.get("connecttimeout", "0"),
            "target": target, "src": src, "src_path": src_path,
            "disp": host + f"·{src}"}


def rc_files():
    """Existing shell rc files (SOURCE env first) for history seeding."""
    out = []
    src = os.environ.get("SOURCE")
    if src:
        out.append(os.path.expanduser(src))
    for p in ("~/.bashrc", "~/.bash_profile", "~/.profile", "~/.bash_aliases",
              "~/.zshrc", "~/.zprofile"):
        out.append(os.path.expanduser(p))
    seen, uniq = set(), []
    for p in out:
        if p not in seen and os.path.isfile(p):
            seen.add(p)
            uniq.append(p)
    return uniq


def history_files():
    """Existing shell history files (HISTFILE env first)."""
    out = []
    hist = os.environ.get("HISTFILE")
    if hist:
        out.append(hist)
    out += [os.path.expanduser("~/.bash_history"),
            os.path.expanduser("~/.zsh_history")]
    seen, uniq = set(), []
    for p in out:
        if p not in seen and os.path.isfile(p):
            seen.add(p)
            uniq.append(p)
    return uniq


def discover_candidates(known):
    """Yield (user, host, port, src, src_path) found in rc files + history,
    deduped, skipping anything already in the static set."""
    seen = set()
    for path in rc_files():
        try:
            with open(path, encoding="utf-8", errors="replace") as f:
                text = f.read()
        except OSError:
            continue
        for ln in text.splitlines():
            if "ssh" not in ln:
                continue
            cand = None
            try:
                cand = ssh_target_from_line(ln)
            except Exception:
                pass
            if cand is None:
                for q in re.findall(r"['\"]([^'\"]*ssh[^'\"]*)['\"]", ln):
                    cand = ssh_target_from_line(q)
                    if cand:
                        break
            if cand and cand[3] != "true":
                user, host, port, _ = cand
                key = (host, port)
                if key in seen:
                    continue
                if host in known or f"{user}@{host}" in known:
                    continue
                seen.add(key)
                yield user, host, port, "rc", path
    for path in history_files():
        lines = tail_lines(path, HISTORY_SCAN_TAIL)
        for ln in lines:
            if ln.startswith(":"):
                ln = ln.split(";", 1)[-1]
            if "ssh" not in ln:
                continue
            cand = ssh_target_from_line(ln)
            if not cand or cand[3] == "true":
                continue
            user, host, port, _ = cand
            key = (host, port)
            if key in seen:
                continue
            if host in known or f"{user}@{host}" in known:
                continue
            seen.add(key)
            yield user, host, port, "hist", path


class Discovery(threading.Thread):
    """Lazily discovers ssh targets from rc files + history; appends them to
    the visible set as they land."""

    def __init__(self, known, checker):
        super().__init__(daemon=True)
        self.known = known
        self.checker = checker
        self.lock = threading.Lock()
        self.found = []
        self.done = False

    def snapshot(self):
        with self.lock:
            return list(self.found)

    def stop(self):
        self.done = True

    def run(self):
        for cand in discover_candidates(self.known):
            if self.done:
                return
            h = make_host(*cand)
            if not h or not h["hostname"]:
                continue
            with self.lock:
                self.found.append(h)
                self.known.add(h["alias"])
            self.checker.add_host(h)
            time.sleep(0.12)  # stagger so they visibly land


