#!/usr/bin/env python3
"""KDC learning agent. Python 3.10+, standard library only."""
import argparse
import grp
import ipaddress
import json
import os
import platform
import pwd
import re
import shlex
import shutil
import socket
import stat
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

VERSION = "0.3.0"

# System paths the agent may inspect. Lessons name their resources with a kdc prefix,
# so checks cannot probe arbitrary files such as /etc/shadow or a user's home.
SYSTEM_PREFIXES = ("/etc/kdc", "/etc/systemd/system/kdc-", "/etc/ssh/sshd_config.d/", "/srv/kdc",
                   "/mnt/kdc", "/opt/kdc", "/home/kdc-", "/var/lib/kdc", "/var/log/kdc", "/usr/local/bin/kdc-",
                   "/etc/profile.d/kdc", "/etc/cron.d/kdc", "/etc/yum.repos.d/kdc", "/etc/logrotate.d/kdc",
                   "/etc/systemd/journald.conf.d/kdc", "/etc/NetworkManager/system-connections/kdc-")
SYSTEM_FILES = ("/etc/fstab", "/etc/hosts", "/etc/selinux/config", "/var/log/journal",
                "/etc/selinux/targeted/contexts/files/file_contexts.local")
NAME = re.compile(r"[a-z_][a-z0-9_-]{0,31}")
UNIT = re.compile(r"[a-zA-Z0-9_.@-]+\.(service|timer|socket|path)")
LOOPBACK = ("127.0.0.1", "localhost", "::1")
PROCESS = re.compile(r"[A-Za-z0-9_.:-]{1,15}")
PACKAGE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,127}")
LAB_HOST = re.compile(r"([a-z0-9-]+\.)*kdc\.lab")


class ApiFailure(Exception):
    def __init__(self, status, message):
        super().__init__(message)
        self.status = status


def validate_api(url):
    parsed = urllib.parse.urlparse(url)
    if parsed.username or parsed.password or parsed.query or parsed.fragment:
        raise ValueError("API URL không được chứa credentials/query/fragment")
    if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1")):
        raise ValueError("Dùng HTTPS, hoặc HTTP loopback khi chạy local")
    return url.rstrip("/")


class Client:
    def __init__(self, api, token=None):
        self.api, self.token = validate_api(api), token

    def post(self, path, data):
        headers = {"Content-Type": "application/json"}
        if self.token:
            headers["Authorization"] = "Bearer " + self.token
        req = urllib.request.Request(self.api + "/v1" + path, json.dumps(data).encode(), headers, method="POST")
        try:
            with urllib.request.urlopen(req, timeout=20) as response:
                return json.loads(response.read(1024 * 1024))
        except urllib.error.HTTPError as exc:
            try:
                message = json.loads(exc.read()).get("error", "API error")
            except (ValueError, KeyError):
                message = "API error"
            raise ApiFailure(exc.code, message) from None


def write_private(path, value):
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    temporary = path.with_suffix(".tmp")
    fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as file:
        json.dump(value, file)
    temporary.chmod(0o600)
    temporary.replace(path)


def load_config(path):
    try:
        value = json.loads(path.read_text())
    except FileNotFoundError:
        raise ValueError("Chưa liên kết server. Chạy kdc.py connect trước.") from None
    if stat.S_IMODE(path.stat().st_mode) & 0o077:
        raise ValueError("Config chứa token phải có quyền 600. Chạy chmod 600 " + str(path))
    return value


def safe_path(root, relative):
    if not isinstance(relative, str) or Path(relative).is_absolute() or ".." in Path(relative).parts:
        raise ValueError("Path không hợp lệ")
    target = (root / relative).resolve()
    if not target.is_relative_to(root.resolve()):
        raise ValueError("Path ra ngoài lab root")
    return target


def allowed_system_path(value):
    return value in SYSTEM_FILES or any(value.startswith(prefix) for prefix in SYSTEM_PREFIXES)


def system_path(value):
    if not isinstance(value, str) or not value.startswith("/") or ".." in value.split("/") or os.path.normpath(value) != value:
        raise ValueError("Path hệ thống không hợp lệ")
    if not allowed_system_path(value):
        raise ValueError("Path nằm ngoài danh sách agent được phép đọc")
    return Path(value)


def config_lines(text):
    """Non-empty lines that are not comments, so '#PasswordAuthentication no' never counts."""
    return [line.strip() for line in text.splitlines() if line.strip() and not line.strip().startswith(("#", ";"))]


def read_small(path):
    # Bound reads; deny special files, sockets and devices.
    if not path.is_file() or path.stat().st_size > 1024 * 1024:
        return None
    return path.read_text(errors="replace")


def unescape_mount(value):
    return re.sub(r"\\([0-7]{3})", lambda match: chr(int(match.group(1), 8)), value)


def check_path(check):
    target = system_path(check["path"])
    try:
        info = os.lstat(target)
    except FileNotFoundError:
        return check.get("absent") is True
    if check.get("absent"):
        return False
    kinds = {"file": stat.S_ISREG, "dir": stat.S_ISDIR, "symlink": stat.S_ISLNK}
    if "kind" in check and not kinds[check["kind"]](info.st_mode):
        return False
    try:
        if "owner" in check and pwd.getpwuid(info.st_uid).pw_name != check["owner"]:
            return False
        if "group" in check and grp.getgrgid(info.st_gid).gr_name != check["group"]:
            return False
    except KeyError:
        return False
    if "mode" in check and stat.S_IMODE(info.st_mode) != int(check["mode"], 8):
        return False
    if "forbid_mode" in check and stat.S_IMODE(info.st_mode) & int(check["forbid_mode"], 8):
        return False
    if "target" in check and (not stat.S_ISLNK(info.st_mode) or os.readlink(target) != check["target"]):
        return False
    if "same_as" in check:
        try:
            other = os.lstat(system_path(check["same_as"]))
        except FileNotFoundError:
            return False
        if (info.st_dev, info.st_ino) != (other.st_dev, other.st_ino):
            return False
    if "context" in check:
        # SELinux label user:role:type:level; compare the type, e.g. httpd_sys_content_t.
        try:
            label = os.getxattr(target, "security.selinux", follow_symlinks=False).decode().rstrip("\0")
        except OSError:
            return False  # No label: SELinux is not in use on this filesystem.
        if label.split(":")[2:3] != [check["context"]]:
            return False
    if "contains" in check:
        real = os.path.realpath(target)
        if not allowed_system_path(real):
            raise ValueError("Symlink trỏ ra ngoài danh sách được phép đọc")
        text = read_small(Path(real))
        return text is not None and any(check["contains"] in line for line in config_lines(text))
    return True


def check_user(check):
    if not NAME.fullmatch(check["name"]):
        raise ValueError("Tên user không hợp lệ")
    try:
        user = pwd.getpwnam(check["name"])
        for name in check.get("groups", []):
            group = grp.getgrnam(name)
            if user.pw_gid != group.gr_gid and check["name"] not in group.gr_mem:
                return False
    except KeyError:
        return False
    if "login" in check:
        can_login = os.path.basename(user.pw_shell) not in ("nologin", "false")
        return can_login == check["login"]
    return True


def check_group(check):
    if not NAME.fullmatch(check["name"]):
        raise ValueError("Tên group không hợp lệ")
    try:
        grp.getgrnam(check["name"])
        return True
    except KeyError:
        return False


def check_systemd(check):
    if not UNIT.fullmatch(check["unit"]) or check["state"] not in ("active", "enabled"):
        raise ValueError("systemd check không hợp lệ")
    command = "is-active" if check["state"] == "active" else "is-enabled"
    result = subprocess.run(["systemctl", command, "--", check["unit"]], capture_output=True, text=True, timeout=10)
    return result.returncode == 0 and result.stdout.strip() == check["state"]


def check_mount(check):
    mountpoint = str(system_path(check["mountpoint"]))
    current = None
    for line in Path("/proc/self/mounts").read_text().splitlines():
        fields = line.split()
        if len(fields) >= 3 and unescape_mount(fields[1]) == mountpoint:
            current = fields  # Later entries are mounted on top of earlier ones.
    if current is None:
        return False
    if "fstype" in check and current[2] != check["fstype"]:
        return False
    if "source" in check:
        if not check["source"].startswith("/dev/"):
            raise ValueError("source phải là block device trong /dev")
        return os.path.realpath(unescape_mount(current[0])) == os.path.realpath(check["source"])
    return True


def fstab_entries():
    fstab = Path("/etc/fstab")
    return [line.split() for line in config_lines(fstab.read_text(errors="replace") if fstab.exists() else "")]


def check_fstab(check):
    mountpoint = str(system_path(check["mountpoint"]))
    for fields in fstab_entries():
        if len(fields) >= 3 and unescape_mount(fields[1]) == mountpoint and check.get("fstype", fields[2]) == fields[2]:
            return True
    return False


def check_tcp_listen(check):
    port = int(check["port"])
    if not 1 <= port <= 65535:
        raise ValueError("Port không hợp lệ")
    tables = [Path("/proc/net/tcp"), Path("/proc/net/tcp6")]
    if not any(table.exists() for table in tables):
        raise OSError("Cần Linux /proc/net/tcp")
    for table in tables:
        if not table.exists():
            continue
        for line in table.read_text().splitlines()[1:]:
            fields = line.split()
            # State 0A is LISTEN; local address is HEX_IP:HEX_PORT.
            if len(fields) > 3 and fields[3] == "0A" and int(fields[1].rsplit(":", 1)[1], 16) == port:
                return True
    return False


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        return None


def check_http(check):
    url = urllib.parse.urlparse(check["url"])
    if url.scheme != "http" or url.hostname not in LOOPBACK or url.username or url.password:
        raise ValueError("HTTP check chỉ dùng http://127.0.0.1, localhost hoặc ::1")
    # No proxy and no redirects: the request never leaves this machine.
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
    try:
        with opener.open(urllib.request.Request(check["url"], method="GET"), timeout=5) as response:
            status, body = response.status, response.read(64 * 1024)
    except urllib.error.HTTPError as exc:
        status, body = exc.code, b""
        exc.close()
    except (urllib.error.URLError, ConnectionError, TimeoutError):
        return False
    if status != check.get("status", 200):
        return False
    return "contains" not in check or check["contains"] in body.decode(errors="replace")


def check_firewall_port(check):
    port, protocol = int(check["port"]), check.get("protocol", "tcp")
    if not 1 <= port <= 65535 or protocol not in ("tcp", "udp"):
        raise ValueError("firewall check không hợp lệ")
    query = "--query-port=%d/%s" % (port, protocol)
    # Runtime and permanent must both allow the port, so it survives reload and reboot.
    for extra in ([], ["--permanent"]):
        result = subprocess.run(["firewall-cmd", *extra, query], capture_output=True, text=True, timeout=15)
        if result.returncode != 0 or result.stdout.strip() != "yes":
            return False
        # A reject/drop rich rule for the port wins over the open port, so the port is still blocked.
        rules = subprocess.run(["firewall-cmd", *extra, "--list-rich-rules"], capture_output=True, text=True, timeout=15).stdout
        if any('port="%d"' % port in rule and re.search(r"\b(reject|drop)\b", rule) for rule in rules.splitlines()):
            return False
    return True


def check_disk_usage(check):
    usage = os.statvfs(system_path(check["path"]))
    used = usage.f_blocks - usage.f_bfree
    available = used + usage.f_bavail
    percent = 100 if available == 0 else -(-used * 100 // available)  # Rounded up, same as df.
    return percent <= int(check["max_percent"])


def check_rebooted_after(check):
    target = system_path(check["path"])
    if not target.exists():
        return False
    boot = next(int(line.split()[1]) for line in Path("/proc/stat").read_text().splitlines() if line.startswith("btime "))
    return boot > os.stat(target).st_mtime


def run_fixed(command, timeout=15):
    """Run one of the fixed, argument-validated commands below; never a shell."""
    return subprocess.run(command, capture_output=True, text=True, timeout=timeout)


def check_firewall_service(check):
    if not PACKAGE.fullmatch(check["service"]):
        raise ValueError("Tên firewalld service không hợp lệ")
    query = "--query-service=" + check["service"]
    answers = [run_fixed(["firewall-cmd", *extra, query]).stdout.strip() for extra in ([], ["--permanent"])]
    if any(answer not in ("yes", "no") for answer in answers):
        raise OSError("firewalld chưa chạy")
    return all(answer == ("no" if check.get("absent") else "yes") for answer in answers)


def processes():
    for entry in Path("/proc").iterdir():
        if not entry.name.isdigit():
            continue
        try:
            line = (entry / "stat").read_text()
            uid = int(next(row.split()[1] for row in (entry / "status").read_text().splitlines() if row.startswith("Uid:")))
        except (OSError, StopIteration):
            continue  # Process exited while scanning.
        rest = line[line.rindex(")") + 2:].split()
        yield {"name": line[line.index("(") + 1:line.rindex(")")], "uid": uid, "nice": int(rest[16])}


def check_process(check):
    if not PROCESS.fullmatch(check["name"]):
        raise ValueError("Tên tiến trình không hợp lệ")
    if not Path("/proc/self/stat").exists():
        raise OSError("Cần Linux /proc")
    try:
        uid = pwd.getpwnam(check["user"]).pw_uid if "user" in check else None
    except KeyError:
        return False
    found = [p for p in processes() if p["name"] == check["name"] and uid in (None, p["uid"])]
    if check.get("absent"):
        return not found
    return bool(found) and all(p["nice"] == check.get("nice", p["nice"]) for p in found)


def check_package(check):
    name, manager = check["name"], check.get("manager", "system")
    if not PACKAGE.fullmatch(name) or manager not in ("system", "flatpak"):
        raise ValueError("package check không hợp lệ")
    if manager == "flatpak":
        installed = run_fixed(["flatpak", "info", "--system", name], timeout=30).returncode == 0
    elif shutil.which("rpm"):
        installed = run_fixed(["rpm", "-q", "--quiet", name]).returncode == 0
    elif shutil.which("dpkg-query"):
        result = run_fixed(["dpkg-query", "-W", "-f=${Status}", name])
        installed = result.returncode == 0 and "install ok installed" in result.stdout
    else:
        raise OSError("Không tìm thấy rpm hoặc dpkg")
    return installed != bool(check.get("absent"))


def check_flatpak_remote(check):
    if not PACKAGE.fullmatch(check["name"]):
        raise ValueError("Tên remote không hợp lệ")
    result = run_fixed(["flatpak", "remotes", "--system", "--columns=name"], timeout=30)
    return result.returncode == 0 and check["name"] in result.stdout.split()


def check_swap(check):
    devices = [line.split()[0] for line in Path("/proc/swaps").read_text().splitlines()[1:] if line.strip()]
    # zram swap is compressed RAM created at boot, not the disk swap the lesson asks for.
    if not [device for device in devices if not device.startswith("/dev/zram")]:
        return False
    return not check.get("persistent", True) or any(len(f) >= 3 and f[2] == "swap" for f in fstab_entries())


def check_ip_address(check):
    wanted = ipaddress.ip_interface(check["address"])
    for link in json.loads(run_fixed(["ip", "-j", "address", "show"]).stdout or "[]"):
        if check.get("interface", link.get("ifname")) != link.get("ifname"):
            continue
        for info in link.get("addr_info", []):
            if ipaddress.ip_interface("%s/%s" % (info["local"], info["prefixlen"])) == wanted:
                return True
    return False


def check_resolve(check):
    if not LAB_HOST.fullmatch(check["name"]):
        raise ValueError("Chỉ phân giải tên thuộc kdc.lab")
    try:
        found = {info[4][0] for info in socket.getaddrinfo(check["name"], None, socket.AF_INET)}
    except socket.gaierror:
        return False
    # With an expected address, a stale second entry still breaks clients that try it first.
    return found == {check["address"]} if "address" in check else bool(found)


def check_selinux(check):
    if check.get("mode", "enforcing") != "enforcing":
        raise ValueError("Chỉ hỗ trợ kiểm tra enforcing")
    enforce = Path("/sys/fs/selinux/enforce")
    if not enforce.exists():
        return False  # SELinux disabled or not supported on this system.
    runtime = enforce.read_text().strip() == "1"
    persistent = any(line.replace(" ", "") == "SELINUX=enforcing" for line in config_lines(Path("/etc/selinux/config").read_text()))
    return runtime and persistent


def check_docker_container(check):
    if not re.fullmatch(r"kdc-[a-z0-9][a-z0-9-]{0,50}", check["name"]):
        raise ValueError("Chỉ kiểm tra container kdc-*")
    result = run_fixed(["docker", "container", "inspect", "--format", "{{json .State}}", check["name"]])
    if result.returncode != 0:
        return False
    state = json.loads(result.stdout)
    return state.get("Running") is True and (not check.get("healthy", False) or state.get("Health", {}).get("Status") == "healthy")


def check_kubernetes_ready(check):
    resource = check["resource"]
    if (check.get("context") != "kind-kdc"
            or not re.fullmatch(r"kdc-[a-z0-9][a-z0-9-]{0,50}", check["namespace"])
            or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", check["name"])
            or resource not in ("deployment", "statefulset", "daemonset", "job", "persistentvolumeclaim")):
        raise ValueError("Chỉ kiểm tra workload của cluster kind-kdc trong namespace kdc-*")
    result = run_fixed(["kubectl", "--context", "kind-kdc", "--namespace", check["namespace"],
                        "--request-timeout=10s", "get", resource, check["name"], "-o", "json"])
    if result.returncode != 0:
        return False
    item = json.loads(result.stdout)
    if item.get("metadata", {}).get("deletionTimestamp"):
        return False
    status = item.get("status", {})
    if resource == "persistentvolumeclaim":
        return status.get("phase") == "Bound"
    if resource == "job":
        return any(c.get("type") == "Complete" and c.get("status") == "True" for c in status.get("conditions", []))
    generation = item.get("metadata", {}).get("generation", 1)
    if status.get("observedGeneration", 0) < generation:
        return False
    desired = item.get("spec", {}).get("replicas", 1)
    if resource == "daemonset":
        desired = status.get("desiredNumberScheduled", 0)
        return desired > 0 and status.get("numberReady", 0) == desired and status.get("updatedNumberScheduled", 0) == desired
    if desired < 1 or status.get("readyReplicas", 0) != desired or status.get("updatedReplicas", 0) != desired:
        return False
    if resource == "statefulset":
        return bool(status.get("currentRevision")) and status.get("currentRevision") == status.get("updateRevision")
    return status.get("availableReplicas", 0) == desired and status.get("unavailableReplicas", 0) == 0


SYSTEM_CHECKS = {"path": check_path, "user": check_user, "group": check_group, "systemd": check_systemd,
                 "mount": check_mount, "fstab": check_fstab, "tcp_listen": check_tcp_listen, "http": check_http,
                 "firewall_port": check_firewall_port, "disk_usage": check_disk_usage,
                 "rebooted_after": check_rebooted_after, "firewall_service": check_firewall_service,
                 "process": check_process, "package": check_package, "flatpak_remote": check_flatpak_remote,
                 "swap": check_swap, "ip_address": check_ip_address, "resolve": check_resolve,
                 "selinux": check_selinux, "docker_container": check_docker_container,
                 "kubernetes_ready": check_kubernetes_ready}


def evaluate(task, root):
    """Fixed check types, no remote shell/eval. Return summaries; never upload file contents."""
    passed, message = False, "Chưa đạt tiêu chí"
    try:
        check = task["check"]
        kind = check["type"]
        if kind in ("file_exists", "file_contains", "file_mode", "json_value"):
            target = safe_path(root, check["path"])
            if kind == "file_exists":
                passed = target.is_file()
            elif kind == "file_contains":
                text = read_small(target)
                passed = text is not None and check["text"] in text
            elif kind == "file_mode":
                passed = target.is_file() and format(stat.S_IMODE(target.stat().st_mode), "03o") == check["mode"]
            elif kind == "json_value":
                text = read_small(target)
                if text is not None:
                    value = json.loads(text)
                    keys = check["keys"]
                    if not isinstance(keys, list) or not 1 <= len(keys) <= 12 or any(not isinstance(key, str) or not key for key in keys):
                        raise ValueError("Đường dẫn JSON không hợp lệ")
                    for key in keys:
                        if isinstance(value, dict):
                            value = value[key]
                        elif isinstance(value, list) and key.isdigit():
                            value = value[int(key)]
                        else:
                            raise ValueError("Không tìm thấy trường JSON")
                    expected = check["value"]
                    numeric = isinstance(expected, (int, float)) and not isinstance(expected, bool)
                    compatible = isinstance(value, (int, float)) and not isinstance(value, bool) if numeric else type(value) is type(expected)
                    passed = compatible and value == expected
        elif kind in SYSTEM_CHECKS:
            passed = SYSTEM_CHECKS[kind](check)
        else:
            raise ValueError("Agent chưa hỗ trợ loại check này")
        hint = task.get("hint")
        message = "Đạt tiêu chí" if passed else ("Chưa đạt: " + hint if isinstance(hint, str) and hint else "Chưa đạt tiêu chí; xem hướng dẫn bài học")
    except (ValueError, KeyError, TypeError, IndexError, AttributeError, StopIteration, OSError, subprocess.SubprocessError):
        message = "Không thể kiểm tra; kiểm tra môi trường hoặc cấu hình task"
    return {"task_id": task["id"], "passed": passed, "message": message[:300]}


def memory_gb():
    if Path("/proc/meminfo").exists():
        kb = next(int(line.split()[1]) for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith("MemTotal:"))
        return kb / 1024 / 1024
    if shutil.which("sysctl"):
        return int(run_fixed(["sysctl", "-n", "hw.memsize"]).stdout) / 1024 ** 3
    return None


def public_ipv4():
    if not shutil.which("ip"):
        return []
    addresses = []
    for link in json.loads(run_fixed(["ip", "-j", "-4", "address", "show"]).stdout or "[]"):
        for info in link.get("addr_info", []):
            address = ipaddress.ip_address(info["local"])
            if address.is_global:
                addresses.append(str(address))
    return addresses


def doctor(args):
    """Inspect this machine only; nothing is sent anywhere."""
    def row(state, label, detail):
        print({True: "✔", False: "✘", None: "–"}[state] + " " + label + ": " + detail)

    linux = platform.system() == "Linux"
    release = Path("/etc/os-release")
    distro = next((line.split("=", 1)[1].strip('"') for line in release.read_text().splitlines() if line.startswith("PRETTY_NAME=")), "") if release.exists() else ""
    row(True, "Hệ điều hành", distro or platform.system() + " " + platform.release())
    arch = {"x86_64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(platform.machine(), platform.machine())
    row(arch in ("amd64", "arm64"), "Kiến trúc", arch)
    row(sys.version_info >= (3, 10), "Python", platform.python_version())
    cpus, ram = os.cpu_count() or 0, memory_gb()
    row(cpus >= 2 if cpus else None, "CPU", "%d core" % cpus)
    row(None if ram is None else ram >= 1.5, "RAM", "không xác định" if ram is None else "%.1f GB" % ram)
    systemd = Path("/run/systemd/system").is_dir()
    row(systemd if linux else None, "systemd", "có" if systemd else "không có (cần cho Module 01)")
    container = False
    if shutil.which("systemd-detect-virt"):
        virt = run_fixed(["systemd-detect-virt"]).stdout.strip() or "none"
        container = run_fixed(["systemd-detect-virt", "--container"]).returncode == 0
        row(False if container else (True if virt != "none" else None), "Ảo hóa",
            virt + (" (container không đủ cho Module 01)" if container else ""))
    marker = Path("/etc/kdc-lab")
    has_marker = marker.is_file() and "KDC-LAB" in marker.read_text(errors="replace")
    row(has_marker if linux else None, "Marker /etc/kdc-lab", "có" if has_marker else "chưa có: không chạy Break/Fix trên máy này")
    row(os.geteuid() == 0 or None, "Quyền", "root" if os.geteuid() == 0 else "user thường (check hệ thống Module 01 cần sudo)")
    public = public_ipv4()
    row(None if not public else False, "IP công khai", ", ".join(public) + ": làm bài SSH hardening trước khi mở port" if public else "không có")
    tools = ["systemctl", "journalctl", "firewall-cmd", "lvm", "flatpak", "nmcli", "curl", "ss", "docker", "kubectl", "helm"]
    row(None, "Công cụ", ", ".join(t + (" ✔" if shutil.which(t) else " ✘") for t in tools))
    try:
        root = Path(load_config(args.config)["lab_root"])
        row(root.is_dir(), "Lab root", str(root))
    except ValueError as exc:
        row(None, "Lab root", str(exc))
    if container:
        print("→ Đây là container: dùng được cho bài workspace. Module 01 cần VM hoặc VPS thật.")
    elif linux and systemd and has_marker:
        print("→ Máy này sẵn sàng cho Module 00–01.")
    elif linux and systemd:
        print("→ Dùng được cho Module 00. Tạo marker theo bài 'Tạo VM Linux dành riêng cho lab' để làm Module 01.")
    else:
        print("→ Dùng được cho bài workspace. Module 01 cần một VM Linux có systemd.")


def connect(args):
    client = Client(args.api)
    pairing = client.post("/agent/pairings", {"name": args.name})
    print("Trên web mở Servers → Liên kết server, nhập:", flush=True)
    print("Mã liên kết: " + pairing["user_code"], flush=True)
    print("Mã xác nhận: " + pairing["challenge"], flush=True)
    print("Mã hết hạn sau 10 phút. Token sẽ được lưu riêng trên server này.", flush=True)
    end = time.monotonic() + pairing["expires_in"]
    while time.monotonic() < end:
        result = client.post("/agent/pairings/poll", {"pairing_id": pairing["pairing_id"], "poll_secret": pairing["poll_secret"]})
        if result["status"] == "linked":
            write_private(args.config, {"api": client.api, "token": result["token"], "device_id": result["device_id"], "lab_root": str(args.lab_root.expanduser().resolve())})
            command = shlex.join(["python3", str(Path(__file__).resolve()), "--config", str(args.config.expanduser().resolve()), "run"])
            print("Đã liên kết. Trên web bật kiểm tra từ xa, rồi chạy: " + command, flush=True)
            return
        time.sleep(2)
    raise ValueError("Liên kết hết hạn. Chạy lại lệnh connect.")


def run(args):
    config = load_config(args.config)
    client = Client(config["api"], config["token"])
    root = Path(config["lab_root"])
    if not root.is_dir():
        raise ValueError("Lab root chưa tồn tại: " + str(root))
    journal = args.config.with_name("pending-result.json")
    print("Agent sẵn sàng. Lab root: " + str(root), flush=True)
    print("Chỉ gửi heartbeat và tóm tắt đạt/chưa đạt, không gửi nội dung file hoặc log.", flush=True)
    last_heartbeat = 0
    while True:
        try:
            if time.monotonic() - last_heartbeat >= 20:
                response = client.post("/agent/heartbeat", {"platform": platform.system(), "python": platform.python_version(), "agent_version": VERSION})
                last_heartbeat = time.monotonic()
                if not response["enabled"]:
                    print("Server đang tắt kiểm tra từ xa. Bật trên web để nhận job.", flush=True)
            if journal.exists():
                pending = json.loads(journal.read_text())
                try:
                    accepted = client.post("/agent/jobs/report", pending)
                    print("Đã gửi kết quả: " + str(accepted["result"]["score"]) + "%", flush=True)
                    journal.unlink()
                except ApiFailure as exc:
                    if exc.status in (403, 404, 409):
                        print("Job đã kết thúc; bỏ kết quả chờ gửi.", flush=True)
                        journal.unlink()
                    else:
                        raise
            else:
                response = client.post("/agent/jobs/claim", {})
                job = response.get("job")
                if job:
                    results = [evaluate(task, root) for task in job["tasks"]]
                    write_private(journal, {"job_id": job["id"], "lease_token": job["lease_token"], "results": results})
                    continue
            if args.once:
                return
        except ApiFailure as exc:
            if exc.status == 401:
                raise ValueError("Token hết hạn hoặc bị thu hồi; chạy connect để liên kết lại.") from None
            print("API tạm chưa sẵn sàng: " + str(exc), flush=True)
        except (urllib.error.URLError, TimeoutError, ConnectionError):
            print("Mất kết nối; sẽ thử lại. Kết quả chờ gửi vẫn lưu trên server.", flush=True)
        time.sleep(args.interval)


def main():
    parser = argparse.ArgumentParser(description="KDC learning agent")
    parser.add_argument("--config", type=Path, default=Path.home() / ".config/kdc/device.json")
    sub = parser.add_subparsers(dest="command", required=True)
    pairing = sub.add_parser("connect")
    pairing.add_argument("--api", default="http://127.0.0.1:4000")
    pairing.add_argument("--name", default=platform.node() or "My server")
    pairing.add_argument("--lab-root", type=Path, default=Path.home() / "kdc-labs")
    sub.add_parser("status")
    sub.add_parser("doctor")
    runner = sub.add_parser("run")
    runner.add_argument("--interval", type=int, default=5, choices=range(2, 61))
    runner.add_argument("--once", action="store_true")
    args = parser.parse_args()
    try:
        if args.command == "connect":
            connect(args)
        elif args.command == "run":
            run(args)
        elif args.command == "doctor":
            doctor(args)
        else:
            config = load_config(args.config)
            print(json.dumps({k: v for k, v in config.items() if k != "token"}, ensure_ascii=False, indent=2))
    except KeyboardInterrupt:
        print("\nĐã dừng agent.")
    except (ValueError, ApiFailure, urllib.error.URLError, OSError) as exc:
        print("Lỗi: " + str(exc), file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
