#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""sysak light_inspect - lightweight one-click system inspection.

Merged with nodehang functionality: collects system/process/IO/memory/
jbd2/stack/resource data in one pass, outputs JSON + text report.
Node-side collection with immediate alerting; deep diagnosis logic
runs on the central side.
"""

import argparse
import json
import os
import signal
import sys
import time
import traceback

import light_inspect_compat as compat
from collectors.system import collect_system
from collectors.cgroup import collect_cgroup_conn
from collectors.ebpf import collect_ebpf
from collectors.gpu import collect_gpu
from collectors.process import collect_process_snapshot, collect_process_top
from collectors import procio, psi, kmsg, resources, cgroups_isolation, stacks
from light_inspect_reporter import Report, SEV_INFO
from light_inspect_rules import DEFAULT_RULES, diagnose
from light_inspect_gates import GATE_DEFAULTS, parse_gates, Deadline


SECTION_DESCRIPTIONS = {
    "system": "系统整体资源概况，包括 CPU、内存、负载、conntrack 与磁盘 IO 统计",
    "cgroup_conn": "按 cgroup 聚合的 TCP/UDP 连接数统计，用于定位高连接数容器/服务",
    "ebpf": "eBPF 程序、kprobe、内核模块与 vmalloc 开销统计",
    "gpu": "NVIDIA GPU 可用性、温度、功耗、ECC 错误与掉卡等异常统计",
    "process": "进程级 CPU、线程数、D 状态与僵尸进程统计，包含 top_sys/top_cpu/top_blkio_wait 三视角",
    "procio": "逐进程 IO 速率（按落盘量与 VFS 量分别排序），定位节点级 IO 元凶",
    "psi": "PSI 压力信息（cpu/memory/io），反映资源竞争程度",
    "kmsg": "内核日志增量扫描（hung_task/softlockup/RCU stall/文件系统错误等）",
    "resources": "系统资源水位（fd/内存/磁盘/进程数/conntrack/socket）",
    "cgroups_iso": "cgroup 资源隔离统计（CPU 限流/内存回收/pids 限额）",
    "tasks": "D/R 态任务内核栈采集与指纹聚类，受 --enable-stacks 控制",
    "jbd": "ext4 journal (jbd2) 线程状态、等待者统计与 /proc/fs/jbd2 运行指标采集，受 --enable-jbd 控制",
}

FIELD_DESCRIPTIONS = {
    "system": {
        "cpu_user_pct": "用户态 CPU 时间占比（%）",
        "cpu_sys_pct": "内核态 CPU 时间占比（%）",
        "cpu_iowait_pct": "CPU 等待 IO 的时间占比（%），高值通常意味着磁盘瓶颈",
        "loadavg_1m": "过去 1 分钟系统平均负载",
        "memory_total_mb": "系统总内存（MB）",
        "memory_available_mb": "系统可用内存（MB），包含可回收缓存",
        "memory_available_pct": "系统可用内存占总内存百分比（%）",
        "dirty_mb": "脏页大小（MB），反映尚未写入磁盘的数据量",
        "vmstat_nr_dirty": "当前脏页数量（页）",
        "vmstat_nr_writeback": "正在回写的页数量（页）",
        "vmstat_nr_dirtied": "自启动以来变脏的页累计数量",
        "vmstat_nr_written": "自启动以来已写回的页累计数量",
        "conntrack_count": "当前 conntrack 条目数",
        "conntrack_pct": "conntrack 使用率（%）",
        "conntrack_available": "conntrack 模块是否可用",
        "disks": "各磁盘读/写次数与扇区数统计",
    },
    "cgroup_conn": {
        "total_cgroups_with_conn": "存在网络连接的 cgroup 总数",
        "top": "连接数最多的 cgroup 列表（established/timewait/total）",
    },
    "ebpf": {
        "kprobe_enabled": "已启用 kprobe 数量",
        "kprobe_disabled": "已禁用 kprobe 数量",
        "bpf_prog_count": "已加载 BPF 程序总数",
        "bpf_jit_bytes": "BPF JIT 分配的内存字节数",
        "bpf_prog_bytes": "BPF 程序分配的内存字节数",
        "kprobe_top": "命中次数最多的 kprobe 列表",
        "top_modules": "按大小排序的内核模块列表",
    },
    "gpu": {
        "available": "GPU 是否可用（nvidia-smi/lspci 检测）",
        "gpu_count": "检测到的 GPU 数量",
        "lost_card_num": "lspci 与 nvidia-smi 检测到的 GPU 数量差（掉卡数）",
        "xid_count": "GPU Xid 错误累计次数",
        "nvswitch_error_count": "NVSwitch 错误累计次数",
        "devices": "各 GPU 设备的利用率、显存、温度、功耗与 ECC 信息",
    },
    "process": {
        "total_procs": "当前 /proc 扫描到的进程总数",
        "total_threads": "当前系统线程总数",
        "d_state_count": "D 状态（不可中断睡眠）进程数",
        "zombie_count": "僵尸进程数",
        "max_threads_per_proc": "单进程最大线程数",
        "max_threads_comm": "线程数最多进程的命令名",
        "max_threads_pid": "线程数最多进程的 PID",
        "top_cpu": "采样窗口内 CPU 使用率最高的进程列表",
        "top_sys": "采样窗口内 sys 时间最高的进程列表（含内核线程）",
        "top_blkio_wait": "采样窗口内 blkio 等待比例最高的进程列表",
        "top_d_state": "D 状态进程列表",
        "attributed_sys_pct_node": "top 进程 sys 总和占节点 CPU 的百分比",
    },
    "procio": {
        "by_disk": "按真实落盘量（read_bytes+write_bytes）排序的进程列表",
        "by_vfs": "按 VFS 字节量（rchar+wchar）排序的进程列表",
        "total_disk_read_bps": "节点总落盘读速率（B/s）",
        "total_disk_write_bps": "节点总落盘写速率（B/s）",
    },
    "psi": {
        "available": "PSI 是否可用",
        "reason": "PSI 不可用时的原因说明",
        "cpu": "CPU 压力（some avg10/60/300）",
        "memory": "内存压力（some/full avg10/60/300）",
        "io": "IO 压力（some/full avg10/60/300）",
    },
    "kmsg": {
        "events": "匹配到的内核日志事件列表（按类型分类）",
        "hung_tasks": "hung_task 报告的卡住任务详情",
    },
    "resources": {
        "fd_usage_pct": "系统 fd 使用率（%）",
        "fd_used": "系统已分配 fd 数量",
        "fd_limit": "系统 fd 上限（file-max）",
        "mem_usage_pct": "内存使用率（%）",
        "disk_full": "磁盘空间使用率超过 80% 的挂载点列表（mount/usage_pct/avail_bytes）",
        "hung_mounts": "statvfs 超时的挂载点列表（可能已夯住）",
        "fd_limit_users": "fd 使用最多的进程列表（pid/comm/fd_count）",
    },
    "cgroups_iso": {
        "version": "cgroup 层级版本（v1/v2/hybrid）",
        "snapshots": "top 进程的 cgroup 限流快照列表",
    },
    "tasks": {
        "clusters": "D/R 态任务栈指纹聚类列表",
        "nr_scanned": "扫描的任务数",
        "truncated": "是否因超时被截断",
        "sys_stacks": "sys 高进程的内核栈列表",
    },
    "jbd": {
        "jbd2_threads": "jbd2 内核线程列表",
        "waiters": "等待 jbd2 handle 的任务统计与样本",
        "journal_stats": "/proc/fs/jbd2/<dev>/info 的 journal 运行统计列表",
    },
}

DEFAULT_LOG_DIR = "/var/log/sysak/light_inspect"
DEFAULT_DURATION = 1
DEFAULT_TOPN = 10
DEFAULT_TIMEOUT = 30

ERROR_EXIT = 2
UNSUPPORTED_EXIT = 3


class TimeoutInterrupt(Exception):
    """Raised by SIGALRM handler to break the main collection loop."""


def _on_timeout(signum, frame):
    raise TimeoutInterrupt()


def _install_timeout(sec):
    if sec <= 0:
        return
    try:
        old = signal.signal(signal.SIGALRM, _on_timeout)
        signal.setitimer(signal.ITIMER_REAL, float(sec))
        return old
    except (ValueError, OSError):
        return None


def _cancel_timeout(old_handler=None):
    try:
        signal.setitimer(signal.ITIMER_REAL, 0)
    except (ValueError, OSError):
        pass
    if old_handler is not None:
        try:
            signal.signal(signal.SIGALRM, old_handler)
        except (ValueError, OSError):
            pass

def parse_args():
    p = argparse.ArgumentParser(
        description="sysak light_inspect - lightweight one-click inspection")
    p.add_argument("--json", action="store_true", help="stdout JSON")
    p.add_argument("-f", "--logfile", default="", help="text log path")
    p.add_argument("-j", "--jsonfile", default="", help="JSON report path")
    p.add_argument("--no-default-log", action="store_true",
                   help="disable default log dir")
    p.add_argument("--duration", type=int, default=DEFAULT_DURATION,
                   help="sampling window seconds")
    p.add_argument("--topn", type=int, default=DEFAULT_TOPN,
                   help="top N output")
    p.add_argument("--gpu-only", action="store_true", help="only GPU")
    p.add_argument("--no-gpu", action="store_true", help="skip GPU")
    p.add_argument("--no-proc", action="store_true",
                   help="skip process collection")
    p.add_argument("--max-pids", type=int, default=4096,
                   help="max PIDs to scan for process collection")
    p.add_argument("-t", "--timeout", type=int, default=DEFAULT_TIMEOUT,
                   help="global hard timeout in seconds")
    p.add_argument("--gate", action="append", default=[],
                   help="gate overrides K=V (repeatable)")
    p.add_argument("--enable-stacks", action="store_true",
                   help="enable D/R state stack collection")
    p.add_argument("--enable-jbd", action="store_true",
                   help="enable jbd2 journal holder collection")
    p.add_argument("--no-procio", action="store_true",
                   help="skip per-process IO collection")
    p.add_argument("--no-psi", action="store_true",
                   help="skip PSI collection")
    p.add_argument("--no-kmsg", action="store_true",
                   help="skip kernel log scan")
    p.add_argument("--no-resources", action="store_true",
                   help="skip resource water-level collection")
    p.add_argument("--no-cgroups-iso", action="store_true",
                   help="skip cgroup isolation collection")
    p.add_argument("--no-cgroup-conn", action="store_true",
                   help="skip cgroup connection collection")
    p.add_argument("--schema", action="store_true",
                   help="output field schema to stdout and exit")
    p.add_argument("--compact", action="store_true",
                   help="compact JSON output (no indent)")
    p.add_argument("-v", "--verbose", action="store_true")
    return p.parse_args()


def ensure_dir(path):
    try:
        compat.makedirs(path, 0o755)
        return None
    except Exception as e:
        return e


def apply_default_paths(args):
    if args.no_default_log:
        return
    err = ensure_dir(DEFAULT_LOG_DIR)
    if err:
        sys.stderr.write("light_inspect: cannot create log dir {}: {}\n".format(
            DEFAULT_LOG_DIR, err))
        return
    if not args.logfile:
        args.logfile = os.path.join(DEFAULT_LOG_DIR, "light_inspect.log")
    if not args.jsonfile:
        args.jsonfile = os.path.join(DEFAULT_LOG_DIR, "light_inspect.json")


def _emit_schema():
    schema = {
        "sections": SECTION_DESCRIPTIONS,
        "fields": FIELD_DESCRIPTIONS,
        "gates": GATE_DEFAULTS,
    }
    out = compat.text_writer(sys.stdout)
    json.dump(schema, out, indent=2, ensure_ascii=False)
    out.write("\n")


def _collect_stacks_section(rep, topn, gates, deadline):
    """Collect D/R state task stacks and sys-process stacks."""
    max_tasks = gates.get("max_stack_tasks", 64)
    max_sys_tasks = gates.get("max_sys_stack_tasks", 16)

    rep.section("tasks")
    sd = stacks.collect_tasks(
        deadline=deadline, limit=gates.get("max_scan_tasks", 4096))
    tasks_list = sd.get("tasks", [])
    rep.kv("nr_scanned", sd.get("nr_scanned", 0))
    rep.kv("truncated", sd.get("truncated", False))

    clusters = stacks.cluster(tasks_list, topn=topn)
    rep.table_begin(["fingerprint", "count", "states", "sample_pids"],
                    [40, 8, 10, 30], name="clusters")
    for c in clusters:
        rep.table_row([
            c.get("fingerprint", ""),
            str(c.get("count", 0)),
            ",".join("{}:{}".format(k, v)
                      for k, v in c.get("state_counts", {}).items()),
            ",".join(str(p) for p in c.get("sample_pids", [])[:5]),
        ])
    rep.table_end()

    # sys-process stacks: take top_sys pids and read their kernel stacks
    sys_stacks = []
    if not deadline.expired() and max_sys_tasks > 0:
        sys_pids = [t["pid"] for t in tasks_list[:max_sys_tasks]
                    if t.get("state") in ("R",)]
        for pid in sys_pids:
            if deadline.expired():
                break
            frames = stacks.read_stack(pid)
            if frames:
                sys_stacks.append({
                    "pid": pid,
                    "comm": stacks.util.proc_comm(pid),
                    "frames": frames[:16],
                    "fingerprint": stacks.fingerprint(frames),
                })
    rep.table_begin(["pid", "comm", "fingerprint", "frames"],
                    [10, 20, 40, 60], name="sys_stacks")
    for s in sys_stacks:
        rep.table_row([
            str(s["pid"]), s["comm"], s["fingerprint"],
            " | ".join(s["frames"][:8]),
        ])
    rep.table_end()

    return {"tasks": tasks_list, "clusters": clusters,
            "sys_stacks": sys_stacks, "nr_scanned": sd.get("nr_scanned", 0),
            "truncated": sd.get("truncated", False)}


def _collect_jbd_section(rep, gates, deadline, stacks_data=None):
    """Collect jbd2 thread status, waiters and journal stats."""
    from jbd import collect as jbd_collect

    rep.section("jbd")

    # 1. jbd2 内核线程状态
    jbd_threads = jbd_collect.find_jbd2_threads()
    rep.kv("nr_jbd2_threads", len(jbd_threads))

    rep.table_begin(["pid", "comm", "device", "state", "stack_top"],
                    [10, 20, 16, 6, 40], name="jbd2_threads")
    for t in jbd_threads:
        rep.table_row([
            str(t.get("pid", "")), t.get("comm", ""),
            t.get("device", ""), t.get("state", ""),
            t.get("stack_top", ""),
        ])
    rep.table_end()

    # 2. D 态等待者统计
    dstate_tasks = (stacks_data or {}).get("tasks", [])
    waiters = jbd_collect.count_waiters(dstate_tasks)
    rep.kv("waiter_count", waiters.get("count", 0))

    rep.table_begin(["pid", "comm", "fingerprint", "top_frames"],
                    [10, 20, 40, 60], name="waiter_samples")
    for s in waiters.get("samples", []):
        rep.table_row([
            str(s.get("pid", "")), s.get("comm", ""),
            s.get("fingerprint", ""),
            " | ".join(s.get("top_frames", [])),
        ])
    rep.table_end()

    # 3. /proc/fs/jbd2/<dev>/info 运行统计（信息增强，不做判定）
    journal_stats = jbd_collect.read_journal_stats()
    rep.kv("nr_journals", len(journal_stats))

    rep.table_begin(["device", "available", "transactions_started",
                     "transactions_committed", "average_commit_time_us",
                     "running_transaction_updates"],
                    [16, 10, 22, 24, 26, 28], name="journal_stats")
    for js in journal_stats:
        rep.table_row([
            js.get("device", ""),
            str(js.get("available", False)),
            str(js.get("transactions_started", "")),
            str(js.get("transactions_committed", "")),
            str(js.get("average_commit_time_us", "")),
            str(js.get("running_transaction_updates", "")),
        ])
    rep.table_end()

    return {
        "jbd2_threads": jbd_threads,
        "waiters": waiters,
        "journal_stats": journal_stats,
    }

def run_inspection():
    args = parse_args()

    if args.schema:
        _emit_schema()
        return 0

    apply_default_paths(args)
    rep = Report("light_inspect", "all", "run")
    rep.set_descriptions(SECTION_DESCRIPTIONS, FIELD_DESCRIPTIONS)
    rules = DEFAULT_RULES.copy()

    # Parse gates and set on report
    gates = parse_gates(args.gate)
    rep.set_gates(gates)
    topn = gates.get("top_n", args.topn)

    # Set up deadline (80% of hard timeout, leaving 20% for output)
    deadline = Deadline(args.timeout * 0.8)
    rep.set_deadline_budget(args.timeout)

    # Install hard timeout
    old_handler = _install_timeout(args.timeout)

    sysdata = {}
    cgdata = {}
    ebpfdata = {}
    procdata = {}
    prociodata = {}
    psidata = {}
    kmsgdata = {}
    resdata = {}
    cgisodata = {}
    stacksdata = {}
    jbddata = {}
    gpudata = {}

    proc_prev = None
    proc_t0 = None
    io_prev = None

    try:
        if not args.gpu_only:
            # Pre-snapshots (instant, before the system sleep window)
            if not args.no_proc:
                proc_t0 = time.time()
                proc_prev = collect_process_snapshot(args.max_pids)

            if not args.no_procio:
                io_prev = procio.sample(limit=args.max_pids)

            # System collection (includes the sampling sleep window)
            rep.section("system")
            sysdata = collect_system(args.duration, topn)
            cpu = sysdata.get("cpu_pct") or {}
            rep.kv("cpu_user_pct", cpu.get("user", "N/A"))
            rep.kv("cpu_sys_pct", cpu.get("sys", "N/A"))
            rep.kv("cpu_iowait_pct", cpu.get("iowait", "N/A"))
            load = sysdata.get("loadavg", {})
            rep.kv("loadavg_1m", load.get("1m", "N/A"))
            mem = sysdata.get("memory_mb", {})
            rep.kv("memory_total_mb", mem.get("total_mb"))
            rep.kv("memory_available_mb", mem.get("available_mb"))
            rep.kv("memory_available_pct", mem.get("available_pct"))
            rep.kv("dirty_mb", mem.get("dirty_mb"))
            vmstat = sysdata.get("vmstat", {})
            rep.kv("vmstat_nr_dirty", vmstat.get("nr_dirty"))
            rep.kv("vmstat_nr_writeback", vmstat.get("nr_writeback"))
            rep.kv("vmstat_nr_dirtied", vmstat.get("nr_dirtied"))
            rep.kv("vmstat_nr_written", vmstat.get("nr_written"))
            ct = sysdata.get("conntrack", {})
            if ct.get("available"):
                rep.kv("conntrack_count", ct.get("count"))
                rep.kv("conntrack_pct", ct.get("pct"))
            else:
                rep.kv("conntrack_available", False)
            rep.table_begin(["disk", "reads", "writes", "rd_sectors",
                              "wr_sectors"],
                             [12, 12, 12, 14, 14], name="disks")
            for d in sysdata.get("disks", []):
                rep.table_row([d["name"], str(d["reads"]),
                               str(d["writes"]),
                               str(d["read_sectors"]),
                               str(d["write_sectors"])])
            rep.table_end()

            if not args.no_cgroup_conn:
                rep.section("cgroup_conn")
                cgdata = collect_cgroup_conn(topn)
                rep.kv("total_cgroups_with_conn",
                       cgdata.get("total_cgroups"))
                rep.table_begin(["cgroup", "established", "timewait",
                                  "total"],
                                 [32, 12, 12, 12], name="top")
                for item in cgdata.get("top", []):
                    rep.table_row([item["cgroup"],
                                   str(item["established"]),
                                   str(item["timewait"]),
                                   str(item["total"])])
                rep.table_end()

            rep.section("ebpf")
            ebpfdata = collect_ebpf(topn)
            rep.kv("kprobe_enabled",
                   ebpfdata.get("kprobes", {}).get("enabled"))
            rep.kv("kprobe_disabled",
                   ebpfdata.get("kprobes", {}).get("disabled"))
            rep.kv("bpf_prog_count", ebpfdata.get("bpf_prog_count"))
            rep.kv("bpf_jit_bytes",
                   ebpfdata.get("vmalloc", {}).get("jit", {}).get("bytes"))
            rep.kv("bpf_prog_bytes",
                   ebpfdata.get("vmalloc", {}).get("prog", {}).get("bytes"))
            rep.table_begin(["func", "hits"], [48, 12], name="kprobe_top")
            for kp in ebpfdata.get("kprobe_top", []):
                rep.table_row([kp.get("func", ""),
                               str(kp.get("hits", 0))])
            rep.table_end()

            mods = ebpfdata.get("modules", [])
            top_mods = sorted(mods, key=lambda x: x.get("size", 0),
                              reverse=True)[:topn]
            rep.table_begin(["module", "size", "used"], [24, 12, 8],
                            name="top_modules")
            for m in top_mods:
                rep.table_row([m["name"], str(m["size"]),
                               str(m["used"])])
            rep.table_end()

            if not args.no_proc and proc_prev is not None:
                proc_t1 = time.time()
                window = proc_t1 - (proc_t0 or proc_t1)
                procdata = collect_process_top(
                    proc_prev, topn, window,
                    max_pids=args.max_pids, deadline=deadline)
                rep.section("process")
                rep.kv("total_procs", procdata.get("total_procs"))
                rep.kv("total_threads", procdata.get("total_threads"))
                rep.kv("d_state_count", procdata.get("d_state_count"))
                rep.kv("zombie_count", procdata.get("zombie_count"))
                rep.kv("max_threads_per_proc",
                       procdata.get("max_threads_per_proc"))
                rep.kv("max_threads_comm",
                       procdata.get("max_threads_comm"))
                rep.kv("max_threads_pid",
                       procdata.get("max_threads_pid"))
                rep.kv("attributed_sys_pct_node",
                       round(procdata.get("attributed_sys_pct_node", 0), 1))
                rep.table_begin(["pid", "comm", "state", "cpu_pct",
                                  "usr_pct", "sys_pct", "nthreads",
                                  "rss_mb"],
                                 [10, 20, 6, 10, 10, 10, 10, 10],
                                 name="top_cpu")
                for pr in procdata.get("top_cpu", []):
                    rep.table_row([
                        str(pr.get("pid", "")), pr.get("comm", ""),
                        pr.get("state", ""),
                        str(pr.get("cpu_pct", "")),
                        str(pr.get("usr_pct", "")),
                        str(pr.get("sys_pct", "")),
                        str(pr.get("nthreads", "")),
                        str(pr.get("rss_mb", ""))])
                rep.table_end()

                rep.table_begin(["pid", "comm", "sys_pct", "cpu_pct",
                                  "blkio_pct"],
                                 [10, 20, 10, 10, 10], name="top_sys")
                for pr in procdata.get("top_sys", []):
                    rep.table_row([
                        str(pr.get("pid", "")), pr.get("comm", ""),
                        str(round(pr.get("sys_pct", 0), 1)),
                        str(round(pr.get("cpu_pct", 0), 1)),
                        str(round(pr.get("blkio_pct", 0), 1))])
                rep.table_end()

                rep.table_begin(["pid", "comm", "blkio_pct", "sys_pct",
                                  "state"],
                                 [10, 20, 10, 10, 6],
                                 name="top_blkio_wait")
                for pr in procdata.get("top_blkio_wait", []):
                    rep.table_row([
                        str(pr.get("pid", "")), pr.get("comm", ""),
                        str(round(pr.get("blkio_pct", 0), 1)),
                        str(round(pr.get("sys_pct", 0), 1)),
                        pr.get("state", "")])
                rep.table_end()

                rep.table_begin(["pid", "comm", "ppid", "nthreads"],
                                [10, 24, 10, 10], name="top_d_state")
                for pr in procdata.get("top_d_state", []):
                    rep.table_row([
                        str(pr.get("pid", "")), pr.get("comm", ""),
                        str(pr.get("ppid", "")),
                        str(pr.get("nthreads", ""))])
                rep.table_end()

            # Per-process IO
            if not args.no_procio and not deadline.expired() and io_prev:
                io_post = procio.sample(limit=args.max_pids,
                                        deadline=deadline)
                window = io_post.get("interval", args.duration) \
                    if "interval" in io_post else \
                    (time.time() - proc_t0 if proc_t0 else args.duration)
                prociodata = procio.compute(io_post, io_prev,
                                             max(window, 1),
                                             topn=topn)
                rep.section("procio")
                rep.kv("interval", round(prociodata.get("interval", 0), 2))
                rep.kv("total_disk_read_bps",
                       int(prociodata.get("total_disk_read_bps", 0)))
                rep.kv("total_disk_write_bps",
                       int(prociodata.get("total_disk_write_bps", 0)))
                rep.table_begin(["pid", "comm", "disk_bps", "read_bps",
                                  "write_bps", "rchar_bps", "wchar_bps"],
                                 [10, 20, 12, 12, 12, 12, 12],
                                 name="by_disk")
                for r in prociodata.get("by_disk", []):
                    rep.table_row([
                        str(r.get("pid", "")), r.get("comm", ""),
                        str(int(r.get("disk_bps", 0))),
                        str(int(r.get("read_bps", 0))),
                        str(int(r.get("write_bps", 0))),
                        str(int(r.get("rchar_bps", 0))),
                        str(int(r.get("wchar_bps", 0)))])
                rep.table_end()

            # PSI
            if not args.no_psi and not deadline.expired():
                psidata = psi.collect()
                rep.section("psi")
                rep.kv("available", psidata.get("available"))
                if psidata.get("reason"):
                    rep.kv("reason", psidata.get("reason"))
                for res in ("cpu", "memory", "io"):
                    rd = psidata.get(res, {})
                    if rd.get("available"):
                        rep.kv(res + "_some_avg10",
                               rd.get("some_avg10", 0))
                        rep.kv(res + "_full_avg10",
                               rd.get("full_avg10", 0))

            # Kernel log
            if not args.no_kmsg and not deadline.expired():
                kmsgdata = kmsg.collect()
                rep.section("kmsg")
                rep.kv("nr_events", len(kmsgdata.get("events", [])))
                rep.table_begin(["type", "msg", "timestamp"],
                                [20, 60, 20], name="events")
                for ev in kmsgdata.get("events", []):
                    rep.table_row([
                        ev.get("type", ""),
                        ev.get("msg", "")[:60],
                        ev.get("ts", "")])
                rep.table_end()
                hung = kmsgdata.get("hung_tasks", [])
                if hung:
                    rep.table_begin(["pid", "comm", "duration", "stack"],
                                    [10, 20, 10, 60], name="hung_tasks")
                    for ht in hung:
                        rep.table_row([
                            str(ht.get("pid", "")),
                            ht.get("comm", ""),
                            str(ht.get("duration", "")),
                            " | ".join(ht.get("frames", [])[:6])])
                    rep.table_end()

            # Resources
            if not args.no_resources and not deadline.expired():
                resdata = resources.collect(deadline=deadline)
                rep.section("resources")
                fd = resdata.get("fd", {})
                if fd.get("max", 0) > 0:
                    rep.kv("fd_usage_pct", fd.get("usage_pct"))
                    rep.kv("fd_used", fd.get("allocated"))
                    rep.kv("fd_limit", fd.get("max"))
                mem = resdata.get("memory", {})
                if mem.get("total_kb", 0) > 0:
                    rep.kv("mem_usage_pct", mem.get("usage_pct"))
                disk_info = resdata.get("disks", {})
                mounts = disk_info.get("mounts", [])
                if mounts:
                    rep.table_begin(["mount", "usage_pct", "avail"],
                                    [32, 10, 12], name="disk_full")
                    for d in mounts:
                        if d.get("usage_pct", 0) > 80:
                            rep.table_row([
                                d.get("target", ""),
                                str(round(d.get("usage_pct", 0), 1)),
                                str(d.get("avail_bytes", ""))])
                    rep.table_end()
                hung = disk_info.get("hung", [])
                if hung:
                    rep.table_begin(["mount", "fstype", "source"],
                                    [32, 12, 24], name="hung_mounts")
                    for d in hung:
                        rep.table_row([
                            d.get("target", ""),
                            d.get("fstype", ""),
                            d.get("source", "")])
                    rep.table_end()
                fd_users = fd.get("top_procs", [])
                if fd_users:
                    rep.table_begin(["pid", "comm", "fd_count"],
                                    [10, 20, 10], name="fd_limit_users")
                    for u in fd_users[:topn]:
                        rep.table_row([
                            str(u.get("pid", "")),
                            u.get("comm", ""),
                            str(u.get("fd_count", ""))])
                    rep.table_end()

            # Cgroup isolation
            if not args.no_cgroups_iso and not deadline.expired():
                cgisodata = {"version": cgroups_isolation.detect_version()}
                rep.section("cgroups_iso")
                rep.kv("version", cgisodata.get("version"))
                top_pids = [r.get("pid") for r in
                            procdata.get("top_sys", [])[:topn]]
                snaps = []
                for pid in top_pids:
                    if deadline.expired():
                        break
                    try:
                        snap = cgroups_isolation.snapshot(pid)
                        snaps.append(snap)
                    except Exception as e:
                        rep.record_error(
                            "cgroup snapshot pid %s: %s" % (pid, e))
                rep.table_begin(["pid", "cgroup", "quota_us",
                                  "throttle_cnt", "mem_usage_pct"],
                                 [10, 30, 12, 12, 12], name="snapshots")
                for i, s in enumerate(snaps):
                    thr = s.get("throttle", {})
                    memcg = s.get("memory", {})
                    ident = s.get("identity", {})
                    pid_val = s.get("pid",
                                    top_pids[i] if i < len(top_pids) else "")
                    rep.table_row([
                        str(pid_val),
                        ident.get("path", ""),
                        str(s.get("cpu", {}).get("quota_us", -1)),
                        str(thr.get("cnt_rate", 0)),
                        str(round(memcg.get("usage_pct", 0), 1))])
                rep.table_end()
                cgisodata["snapshots"] = snaps

            # Stacks (heavy, gated)
            if args.enable_stacks and not deadline.expired():
                stacksdata = _collect_stacks_section(rep, topn, gates,
                                                      deadline)

            # JBD (heavy, gated)
            if args.enable_jbd and not deadline.expired():
                jbddata = _collect_jbd_section(rep, gates, deadline,
                                                stacksdata)

        # GPU collection (not gated by deadline -- it's lightweight)
        if not args.no_gpu:
            rep.section("gpu")
            gpudata = collect_gpu()
            rep.kv("available", gpudata.get("available"))
            if gpudata.get("available"):
                rep.kv("gpu_count", gpudata.get("gpu_count"))
                rep.kv("lost_card_num", gpudata.get("lost_card_num"))
                rep.kv("xid_count", gpudata.get("xid_count"))
                rep.kv("nvswitch_error_count",
                       gpudata.get("nvswitch_error_count"))
                rep.table_begin(["idx", "name", "util", "mem", "temp",
                                  "power", "ecc_uncorr"],
                                 [6, 24, 8, 8, 8, 18, 12], name="devices")
                for dev in gpudata.get("devices", []):
                    rep.table_row([
                        dev.get("index", ""), dev.get("name", ""),
                        str(dev.get("util", "N/A")),
                        str(dev.get("mem_util_pct", "N/A")),
                        str(dev.get("temp", "N/A")),
                        "{}W/{}W".format(
                            dev.get("power_draw", "?"),
                            dev.get("power_limit", "?")),
                        str(dev.get("ecc_uncorr", "N/A")),
                    ])
                rep.table_end()
            else:
                rep.alert(SEV_INFO, "GPU not available: {}".format(
                    gpudata.get("reason", "")))

    except TimeoutInterrupt:
        rep.mark_truncated()
        rep.record_decision("timeout",
                            "hard timeout %ss reached" % args.timeout)
    except Exception as e:
        rep.record_error("%s: %s" % (type(e).__name__, str(e)))
        if args.verbose:
            traceback.print_exc()

    # Mark truncated if deadline was exceeded
    if deadline.truncated:
        rep.mark_truncated()

    # Run diagnosis
    diagnose(rep, sysdata, cgdata, ebpfdata, gpudata, procdata, rules,
             psidata=psidata, prociodata=prociodata, kmsgdata=kmsgdata,
             resdata=resdata, cgisodata=cgisodata,
             stacksdata=stacksdata, jbddata=jbddata)

    # Output
    if args.json:
        rep.dump_json(compat.text_writer(sys.stdout),
                      indent=0 if args.compact else 2)
    else:
        rep.dump_text(compat.text_writer(sys.stdout))

    if args.logfile:
        try:
            with compat.open_write(args.logfile) as f:
                rep.dump_text(f)
        except Exception as e:
            sys.stderr.write(
                "light_inspect: cannot write logfile {}: {}\n"
                .format(args.logfile, e))
    if args.jsonfile:
        try:
            with compat.open_write(args.jsonfile) as f:
                rep.dump_json(f, indent=0 if args.compact else 2)
        except Exception as e:
            sys.stderr.write(
                "light_inspect: cannot write jsonfile {}: {}\n"
                .format(args.jsonfile, e))

    if (args.logfile or args.jsonfile) and sys.stderr.isatty():
        if args.logfile:
            sys.stderr.write("light_inspect: log  -> {}\n"
                             .format(args.logfile))
        if args.jsonfile:
            sys.stderr.write("light_inspect: json -> {}\n"
                             .format(args.jsonfile))

    _cancel_timeout(old_handler)

    return 0 if rep.level == 0 else 1


def _emit_failure(message, suggestion):
    """Print a parseable report so stdout is never empty on failure."""
    payload = {
        "tool": "light_inspect",
        "error": message,
        "alerts": [{"severity": "CRIT", "message": message}],
        "diagnosis": {
            "level": "critical",
            "issue_flags": 0,
            "causes": [message],
            "suggestions": [suggestion],
        },
    }
    out = compat.text_writer(sys.stdout)
    json.dump(payload, out, indent=2, ensure_ascii=False)
    out.write("\n")
    out.flush()


def main():
    if sys.version_info[:2] < compat.MIN_PYTHON:
        _emit_failure(
            "light_inspect requires Python %d.%d or later, current interpreter is %s"
            % (compat.MIN_PYTHON[0], compat.MIN_PYTHON[1],
               sys.version.split()[0]),
            "install python2.7+/python3 on this host, then rerun sysak light_inspect")
        return UNSUPPORTED_EXIT
    try:
        return run_inspection()
    except Exception as e:
        if "-v" in sys.argv or "--verbose" in sys.argv:
            traceback.print_exc()
        _emit_failure(
            "light_inspect aborted: %s: %s" % (
                type(e).__name__, compat.exception_text(e)),
            "rerun with -v to get the full traceback")
        return ERROR_EXIT


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