"""sshj.connect — banner, connect(), and main() (composition root).

B2 step 4d: moved verbatim from sshj_cli.py (single-file era) — no
behavior change. main() is the TOP of the dependency graph: it
constructs every subsystem and hands them together; nothing imports
connect (except the __init__/__main__ entry shims, which are the thin
launcher / `python -m` seams). Owns the ANSI_* banner colors and the
exit-code contract (0 ok / 1 cannot run / 2 no match / 130 abort /
N ssh's rc). Edges: every module (composition root by design).
"""

import os
import shlex
import subprocess
import sys
import time

from .cmd import DOWN_CONNECT_TIMEOUT, ssh_cmd_args, ssh_cmd_str
from .discover import (Discovery, discover_candidates, expand_hosts,
    make_host, parse_configs, resolve)
from .fuzzy import direct_select, sort_by_last_used
from .lastused import record_last_used, record_last_success, seed_from_history
from .probe import PROBE_TIMEOUT, Checker, probe
from .run import _insert_into_input, _insert_via_relay, pick
from . import selftest   # B5: --selftest (leaf: env probes, never crashes)
from .doctor import run as doctor_run  # SG1: `sshj doctor` (+ --info tier 2)
from .updatecheck import run as update_check_run  # F4: `sshj update-check`
from .scan import SpeedScan
from .state import Conns
from .tailnet import (magicdns_on, merge_tailnet, self_dnsname,
    tailnet_peers)
from .term import _GRID_BOTTOM_ROW, _stdout_is_tty
from .theme import VER, detect_theme
from .ui import STATUS_GLYPH, apply_theme, ms_fmt


# --------------------------- connect -----------------------------
ANSI = {"up": "\033[32m", "down": "\033[31m", "check": "\033[33m",
        None: "", "": ""}
ANSI_DIM = "\033[2m"
ANSI_BOLD = "\033[1m"
ANSI_END = "\033[0m"


def _err(text):
    sys.stderr.write(text + "\n")
    sys.stderr.flush()


HELP = f"""sshj {VER} — an atuin-style SSH target picker (inline grid at your prompt line)

Usage:
  sshj              open the grid (hosts from ssh config, tailnet, history/rc)
  sshj 1            run the LAST connection directly (row 1 = most-recently-used)
  sshj <string>     fuzzy direct: best match on name/address/username, execute
  sshj -<flag> …    first arg starting with '-' = ssh passthrough, not direct mode
  sshj --selftest   probe the environment (tty, size, kernel, shell, relay,
                    python) and exit 0/1/2 — no grid
  sshj doctor       machine-readable self-diagnostics (JSON; --info tier 2)
  sshj update-check check the published version against this one and set the
                    update flag (the one explicit network command; --now
                    bypasses the once-a-day cache)
  sshj --help       this text

Passthrough: any ssh flag placed before the target passes to ssh verbatim
(value-taking: -l -p -i -o -F -E -e -c -J -L -R -D -W -M -S -I -s).
Examples: sshj -t wsl · sshj -p 2222 myhost · sshj -o ConnectTimeout=5 -- host

In the grid:
  arrows        move        PgUp/PgDn  page       Home/End   first/last row
  type          fuzzy filter over name, address, username (no letter is a
                grid key by default — every character searches)
  digits + Enter numpick (1-based into the filtered list)
  Enter         connect to the selected row
  Tab           two-stage, the ADDRESS command (the name swapped for
                user@host — the footer's right side): Tab-1 stages it into
                the [source] line, Tab-2 commits it to your shell input
                field (editable); Enter runs it there, Ctrl-C discards
  Right         two-stage, the ALIAS command (for an alias, just
                `ssh <name>` — runnable as-is, no `--`): right-1 stages it
                into the [source] line, right-2 places it in your shell
                input field (same placement as the Tab commit)
  Ctrl-R        cycle sort: last used -> last SUCCESS -> alphabetical
                (the active mode shows at the input line's right edge;
                last-success sorts by the last rc==0 connection)
  Left          cancel: empty input field closes sshj outright; with a
                staged command / filter, clears it first, then closes
  Ctrl-T/X/Y/N/A/F/V   toggle -t -X -Y -N -A -f -v (shown in the footer,
                ridden into the final command)
  Esc          quit (restores your terminal)           mouse wheel: scroll

Keymap & sort: ~/.config/sshj/config.json (SSHJ_CONFIG_FILE overrides) —
  {{ "sort": "last" | "last_success" | "alpha",
    "keymap": {{ "down": ["down", "j"], "up": ["up", "k"] }} }}
No single letter is bound by default. Binding one (vim-style j/k) is
opt-in and deliberate; a bound single-char key acts as a grid key only
while the input field is EMPTY — the moment it has any content (even a
leading space), it and everything after it is search text.

Exit codes: 0 ok · 1 cannot run (terminal too small, < 8 rows or < 50 cols) ·
2 no match (direct mode) · 130 user quit · otherwise ssh's own exit code.

Environment: SSHJ_THEME (auto|nord|light|plain), NO_COLOR, SSHJ_STATE_DIR,
SSHJ_SSH_CMD (dry-run hook; also skips probing), SSHJ_PROBE_CONCURRENCY,
SSHJ_MAGICDNS, SSHJ_UPDATE (force the "- UPDATE" banner), SSHJ_UPDATE_URL
(update-check endpoint) — full table: sshj --selftest output and
docs/reference.md.
"""


def _banner_open(host, status):
    if not sys.stderr.isatty():
        return
    g = STATUS_GLYPH.get(status, "·")
    _err(f"{ANSI[status]}{ANSI_BOLD}{g} {host['alias']}{ANSI_END} "
         f"{ANSI_DIM}{host['target']}{ANSI_END} — connecting")


def _banner_close(host, status, rc, elapsed):
    if not sys.stderr.isatty():
        return
    g = STATUS_GLYPH.get(status, "·")
    _err(f"{ANSI_DIM}← {g} {host['alias']} · {elapsed:.0f}s · "
         f"exit {rc}{ANSI_END}")


def connect(host, extra_args, status):
    """Run (or dry-run via SSHJ_SSH_CMD) the ssh for a selected host; routes ssh's stdio to /dev/tty when stdout is piped."""
    args = ssh_cmd_args(host, status, extra_args)
    cmd = os.environ.get("SSHJ_SSH_CMD")  # test hook: e.g. "echo DRY-RUN"
    if cmd:
        _banner_open(host, status)
        print("$ " + cmd + " " + " ".join(shlex.quote(a) for a in args))
        _banner_close(host, status, 0, 0.0)
        record_last_used(host["alias"])
        record_last_success(host["alias"])
        sys.exit(0)
    _banner_open(host, status)
    _err(f"{ANSI_DIM}$ {' '.join(shlex.quote(a) for a in args)}{ANSI_END}")
    # When sshj runs inside the bash completion widget ($(sshj …)), its
    # stdout is a PIPE — but an interactive ssh session must talk to the
    # real terminal. Route ssh's stdin/stdout/stderr to /dev/tty when
    # stdout is captured (the grid + banners already do their own routing).
    tty = None
    if not _stdout_is_tty():
        try:
            tty = os.open("/dev/tty", os.O_RDWR)
        except OSError:
            tty = None
    t0 = time.time()
    try:
        if tty is not None:
            r = subprocess.run(args, stdin=tty, stdout=tty, stderr=tty)
        else:
            r = subprocess.run(args)
        rc = r.returncode
    except KeyboardInterrupt:
        rc = 130
    except Exception:
        rc = 255
    finally:
        if tty is not None:
            try:
                os.close(tty)
            except OSError:
                pass
    record_last_used(host["alias"])
    if rc == 0:
        # a genuine successful connection: it now sorts first in the
        # "last success" order. A failed attempt updated last_used (it was
        # tried) but must NOT move the host up there.
        record_last_success(host["alias"])
    _banner_close(host, status, rc, time.time() - t0)
    if rc:
        print(f"\nsshj: ssh exited {rc}", file=sys.stderr)
    sys.exit(rc)


def main():
    """Composition root: build every subsystem, hand them to the grid, connect or exit per the exit-code contract."""
    extra_args = sys.argv[1:]
    # B5: --selftest is first-class — probe the environment, print the
    # table to stdout, exit 0/1/2 BEFORE theme/threads/raw mode. It never
    # touches the terminal (no DSR, no raw mode) and writes no state.
    if "--selftest" in extra_args:
        sys.exit(selftest.run(VER))
    # SG1: `sshj doctor` is first-class — build the diagnostic document
    # (JSON + bug-report header), exit 0/1 BEFORE theme/threads/raw mode.
    # --info is handled inside (tier 2: env/config/state, no probes).
    if extra_args and extra_args[0] == "doctor":
        sys.exit(doctor_run(extra_args[1:]))
    # F4: `sshj update-check` is the ONE explicit network surface — fetch the
    # published version, compare with VER, set update.flag when newer.
    # Never on the default path (the grid's "- UPDATE" reads the local flag).
    if extra_args and extra_args[0] == "update-check":
        sys.exit(update_check_run(extra_args[1:]))
    apply_theme(detect_theme())      # B15: nord default, SSHJ_THEME override
    if extra_args and (extra_args[0] in ("-h", "--help")):
        print(HELP)
        sys.exit(0)

    entries = parse_configs()
    hosts = []
    for e in expand_hosts(entries):
        info = resolve(e["alias"], e["opts"])
        h = {"alias": e["alias"], **info, "disp": e["alias"],
             "from": e.get("from", "")}
        target = info["hostname"] or h["alias"]
        if info["port"] and info["port"] != "22":
            target += f":{info['port']}"
        if info["user"]:
            target = f"{info['user']}@{target}"
        h["target"] = target
        hosts.append(h)

    # B12: fold the tailnet in (dedupe against the config rows). Runs before
    # `known` is built so tailnet IPs are seen by rc/history discovery too.
    # Degrades to a no-op when tailscale is absent / the call fails (C3).
    self_ip, peers = tailnet_peers()
    if peers:
        mdns_on = os.environ.get("SSHJ_MAGICDNS", "1") != "0" \
            and magicdns_on(self_dnsname())
        hosts, _ts_new = merge_tailnet(hosts, self_ip, peers, mdns_on)

    last_used = seed_from_history(hosts)
    known = set()
    for h in hosts:
        known.add(h["alias"])
        if h.get("hostname"):
            known.add(h["hostname"])
        if h.get("user") and h.get("hostname"):
            known.add(f"{h['user']}@{h['hostname']}")

    checker = Checker(hosts)
    discovery = Discovery(known, checker)
    conns = Conns(lambda: hosts + discovery.snapshot())
    conns.first_pass()
    speed_scan = SpeedScan(checker)   # B14: up/down bandwidth for up-hosts

    if not hosts:
        print("sshj: no hosts in config — waiting for history/rc scan…",
              file=sys.stderr)

    # ---- direct execution (B8/B9) -------------------------------------
    # `sshj 1`          -> run the LAST connection (row 1 of the grid:
    #                      last_used first; a quick probe decides the
    #                      ConnectTimeout exactly like the grid does).
    # `sshj <string>`   -> best fuzzy match across NAME, ADDRESS and
    #                      USERNAME, executed; ties keep last-used order.
    # A flag (anything starting with '-') is NOT direct mode: it is an
    # ssh passthrough to the grid, as before.
    if extra_args and not extra_args[0].startswith("-"):
        q = extra_args[0]
        pass_args = tuple(extra_args[1:])
        # same pool as the grid: config hosts + rc/history candidates
        # (bounded: direct mode must not stall on huge histories; ssh -G
        # is called once per candidate, so cap the discovery sweep)
        pool = list(hosts)
        n_disc = 0
        for cand in discover_candidates(known):
            if n_disc >= 200:
                break
            dh = make_host(*cand)
            if dh and dh.get("hostname"):
                pool.append(dh)
                n_disc += 1
        pool = sort_by_last_used(pool, last_used)
        chosen, cands = direct_select(pool, q, last_used)
        if chosen is None:
            print(f"sshj: no match for {q!r} "
                  f"({len(hosts)} config + {len(pool) - len(hosts)} "
                  f"discovered hosts)", file=sys.stderr)
            sys.exit(2)
        if len(cands) > 1:
            best = cands[0]
            for h in cands[1:]:
                _err(f"{ANSI_DIM}  ~ {h['disp']}  {h.get('target', '')}{ANSI_END}")
            _err(f"{ANSI_DIM}best match: {best['disp']}{ANSI_END}")
        status = None
        # the dry-run hook (SSHJ_SSH_CMD) means no real ssh: no probe either
        if not os.environ.get("SSHJ_SSH_CMD"):
            try:
                up, ms = probe(chosen, PROBE_TIMEOUT)
                status = "up" if up else "down"
                if up and ms is not None:
                    print(f"{ANSI_DIM}{STATUS_GLYPH['up']} {chosen['disp']} "
                          f"{ms_fmt(ms)}{ANSI_END}", file=sys.stderr)
                else:
                    print(f"{ANSI_DIM}{STATUS_GLYPH['down']} {chosen['disp']} "
                          f"down — trying with {DOWN_CONNECT_TIMEOUT}s timeout"
                          f"{ANSI_END}", file=sys.stderr)
            except Exception:
                pass  # probe must never block a direct exec
        connect(chosen, pass_args, status)

    chosen, checker, mode, text, toggled = pick(
        hosts, last_used, checker, discovery, conns, extra_args, speed_scan)
    if chosen is None:
        sys.exit(130)
    status = checker.status.get(chosen["alias"]) if checker else None
    # B11: the command that runs is the passthrough + the toggled flags
    # (already shown in the footer/band, and already baked into `text` for
    # the insert path — so this must match exactly what was displayed).
    eff_args = tuple(sorted(toggled)) + tuple(extra_args)
    if mode == "insert":
        # tab: leave the command in the shell's INPUT FIELD.
        #  - inside $(...)  (the ~/.bashrc completion widget): write it to
        #    stdout, which the widget captures and sets into READLINE_LINE.
        #    (TIOCSTI was removed from the Linux kernel in 6.2 — WSL2 — so
        #    this readline callback is the only way to fill the line; same
        #    mechanism as atuin/fzf above.)
        #  - typed directly (stdout is the tty, no widget): stuff it into
        #    the line if the kernel allows, else print it as a command line.
        if not text:
            text = ssh_cmd_str(chosen, status, eff_args)
        if _stdout_is_tty():
            # typed directly (no widget): place the command EDITABLY in the
            # shell's input field. TIOCSTI where the kernel allows it, else
            # the relay (WSL2 >= 6.2); print only as a last resort.
            if not _insert_into_input(text):
                if not _insert_via_relay(text,
                                         _GRID_BOTTOM_ROW[0] or ""):
                    print(text)
        else:
            # inside $(...) the widget captures stdout -> READLINE_LINE
            print(text)
        sys.exit(0)
    connect(chosen, eff_args, status)


if __name__ == "__main__":
    main()
