#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""本机日志采集分析 —— 对外命令入口。

遵循《Agent CLI 适配规范 1.0》：
  * 名词—动词结构，逐层帮助（--help / help <noun> / help <noun> <verb>）
  * 数据只写 stdout，日志/进度/告警只写 stderr
  * 非 TTY 默认输出 JSON（无颜色、无交互、无分页）
  * 退出码：0 成功 / 1 一般错误 / 2 用法错误 / 3 未找到 / 4 权限不足 / 5 冲突 / 10 dry-run 通过
  * 写操作支持 --dry-run（返回结构化 target/changes/risks/reversible）
  * 破坏性操作 TTY 下需确认，自动化环境用 --yes 跳过
  * 支持 --if-exists=error|skip|force 的幂等控制

本文件是唯一对外入口；collect_logs.sh 等为内部实现，不直接暴露给智能体。
"""

from __future__ import print_function

import json
import os
import re
import signal
import subprocess
import sys
import time
import glob

EXIT_OK = 0
EXIT_ERROR = 1
EXIT_USAGE = 2
EXIT_NOT_FOUND = 3
EXIT_PERMISSION = 4
EXIT_CONFLICT = 5
EXIT_DRYRUN_OK = 10

SKILL_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
SCRIPT_DIR = os.path.join(SKILL_DIR, "scripts")
CONFIG_DIR = os.path.join(SKILL_DIR, "config")
STATE_DIR = "/var/tmp/local-log-collect"
MINI_KDUMP_DIR = "/var/crash/mini-kdump"

ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")


def strip_ansi(text):
    """去掉终端颜色/光标控制符，保证错误文本干净可读"""
    return ANSI_RE.sub("", text)

IS_TTY = sys.stdout.isatty()
IS_ROOT = (os.geteuid() == 0)


# --------------------------------------------------------------------------- #
# 输出与错误
# --------------------------------------------------------------------------- #
class CliError(Exception):
    def __init__(self, code, error, message, suggestion=None, retryable=False):
        Exception.__init__(self, message)
        self.code = code
        self.error = error
        self.message = message
        self.suggestion = suggestion
        self.retryable = retryable


def _is_tty_out():
    return IS_TTY


def emit(data, fmt="json"):
    """把数据写到 stdout（stdout 只允许出数据）"""
    if fmt == "json":
        sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
    elif fmt == "ndjson":
        rows = data if isinstance(data, list) else [data]
        for row in rows:
            sys.stdout.write(json.dumps(row, ensure_ascii=False) + "\n")
    elif fmt == "table":
        sys.stdout.write(render_table(data) + "\n")
    else:
        sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
    sys.stdout.flush()


def render_table(data):
    """极简表格渲染（只用于人类查看，字段与 JSON 完全一致）"""
    if isinstance(data, dict):
        rows = data.get("items")
        if not isinstance(rows, list):
            return "\n".join("%-18s %s" % (k, v) for k, v in data.items())
    elif isinstance(data, list):
        rows = data
    else:
        return str(data)
    if not rows:
        return "(空)"
    if all(isinstance(r, dict) for r in rows):
        cols = []
        for r in rows:
            for k in r.keys():
                if k not in cols:
                    cols.append(k)
        widths = [len(c) for c in cols]
        cells = []
        for r in rows:
            line = []
            for i, c in enumerate(cols):
                v = r.get(c, "")
                if isinstance(v, (dict, list)):
                    v = json.dumps(v, ensure_ascii=False)
                v = str(v)
                line.append(v)
                if len(v) > widths[i]:
                    widths[i] = len(v)
            cells.append(line)
        out = ["  ".join(c.ljust(widths[i]) for i, c in enumerate(cols))]
        out.append("  ".join("-" * w for w in widths))
        for line in cells:
            out.append("  ".join(c.ljust(widths[i]) for i, c in enumerate(line)))
        return "\n".join(out)
    return "\n".join(str(r) for r in rows)


def log(msg):
    sys.stderr.write("[local-log-collect] %s\n" % msg)
    sys.stderr.flush()


def fail(code, error, message, suggestion=None, retryable=False):
    raise CliError(code, error, message, suggestion, retryable)


def report_error(exc, fmt):
    payload = {
        "error": exc.error,
        "message": exc.message,
        "suggestion": exc.suggestion or "",
        "retryable": exc.retryable,
    }
    if _is_tty_out():
        sys.stderr.write("错误：%s\n" % exc.message)
        if exc.suggestion:
            sys.stderr.write("建议：%s\n" % exc.suggestion)
    else:
        sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
    return exc.code


# --------------------------------------------------------------------------- #
# 工具函数
# --------------------------------------------------------------------------- #
def run(cmd, timeout=None, check=False):
    """执行内部命令；返回 (rc, stdout, stderr)，输出统一去除终端颜色控制符"""
    try:
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        out, err = p.communicate(timeout=timeout)
        return (p.returncode,
                strip_ansi(out.decode("utf-8", "replace")),
                strip_ansi(err.decode("utf-8", "replace")))
    except subprocess.TimeoutExpired:
        p.kill()
        return (124, "", "timeout after %ss" % timeout)
    except OSError as e:
        if check:
            fail(EXIT_NOT_FOUND, "command_missing", "无法执行 %s：%s" % (cmd[0], e))
        return (127, "", str(e))


def which(name):
    for d in os.environ.get("PATH", "").split(os.pathsep):
        p = os.path.join(d, name)
        if os.path.isfile(p) and os.access(p, os.X_OK):
            return p
    return None


def du_bytes(path):
    if os.path.isfile(path):
        try:
            return os.path.getsize(path)
        except OSError:
            return 0
    total = 0
    for root, _dirs, files in os.walk(path):
        for f in files:
            try:
                total += os.path.getsize(os.path.join(root, f))
            except OSError:
                pass
    return total


def human(n):
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if n < 1024 or unit == "TB":
            return "%.1f%s" % (n, unit) if unit != "B" else "%dB" % n
        n /= 1024.0


def check_out_path(path):
    """输出目录校验：拒绝遍历与系统敏感目录"""
    if not path:
        fail(EXIT_USAGE, "invalid_argument", "--out 不能为空")
    if ".." in path.split(os.sep):
        fail(EXIT_USAGE, "invalid_argument", "--out 不允许包含 .. 路径遍历")
    ap = os.path.abspath(path)
    for bad in ("/proc", "/sys", "/dev", "/boot", "/etc", "/usr", "/bin", "/sbin", "/lib"):
        if ap == bad or ap.startswith(bad + os.sep):
            fail(EXIT_USAGE, "invalid_argument",
                 "--out 不允许写入系统目录 %s" % bad,
                 "请改用 /tmp 或用户数据目录")
    return ap


def check_file_path(path, must_exist=True):
    if not path:
        fail(EXIT_USAGE, "invalid_argument", "--file 不能为空")
    if ".." in path.split(os.sep):
        fail(EXIT_USAGE, "invalid_argument", "--file 不允许包含 .. 路径遍历")
    ap = os.path.abspath(path)
    if must_exist and not os.path.isfile(ap):
        fail(EXIT_NOT_FOUND, "not_found", "文件不存在：%s" % ap)
    return ap


TIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$")


def check_time(value, flag):
    if value is None:
        return None
    if not TIME_RE.match(value):
        fail(EXIT_USAGE, "invalid_argument",
             "%s 格式应为 'YYYY-MM-DD HH:MM'，收到：%s" % (flag, value))
    return value


def require_root(action):
    if not IS_ROOT:
        fail(EXIT_PERMISSION, "permission_denied",
             "%s需要管理员权限" % action,
             "请以管理员身份重新执行该命令", retryable=True)


def confirm(prompt):
    if os.environ.get("LOCAL_LOG_COLLECT_YES") == "1":
        return True
    if not sys.stdin.isatty():
        fail(EXIT_USAGE, "confirmation_required",
             "非交互环境执行破坏性操作需要显式 --yes",
             "如需确认执行请追加 --yes")
    sys.stderr.write("%s [y/N] " % prompt)
    sys.stderr.flush()
    ans = sys.stdin.readline().strip().lower()
    return ans in ("y", "yes")


def load_catalog():
    path = os.path.join(CONFIG_DIR, "catalog.json")
    if not os.path.isfile(path):
        fail(EXIT_NOT_FOUND, "not_found", "模块清单缺失：%s" % path)
    with open(path, "r") as fh:
        return json.load(fh)


def state_path(name):
    return os.path.join(STATE_DIR, name)


def read_state(name):
    p = state_path(name)
    if not os.path.isfile(p):
        return None
    try:
        with open(p, "r") as fh:
            return json.load(fh)
    except (ValueError, OSError):
        return None


def write_state(name, data):
    if not os.path.isdir(STATE_DIR):
        os.makedirs(STATE_DIR, 0o700)
    with open(state_path(name), "w") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)


def clear_state(name):
    p = state_path(name)
    if os.path.isfile(p):
        os.remove(p)


def dry_run_result(target, changes, risks, reversible, extra=None):
    out = {
        "dry_run": True,
        "target": target,
        "changes": changes,
        "risks": risks,
        "reversible": reversible,
    }
    if extra:
        out.update(extra)
    if not _is_tty_out():
        emit(out)
    else:
        sys.stdout.write("试运行：%s\n变更：%s\n风险：%s\n可逆：%s\n"
                         % (target, "; ".join(changes), "; ".join(risks), "是" if reversible else "否"))
    return EXIT_DRYRUN_OK


# --------------------------------------------------------------------------- #
# 命令实现
# --------------------------------------------------------------------------- #
def cmd_catalog_list(opts):
    cat = load_catalog()
    expand = os.path.expanduser("~")
    items = []
    for m in cat["modules"]:
        exists = False
        for p in m["paths"]:
            rp = p.replace("~", expand)
            if os.path.exists(rp):
                exists = True
                break
        items.append({
            "id": m["id"],
            "name": m["name"],
            "domain": m["domain"],
            "paths": m["paths"],
            "exists": exists,
        })
    if opts.get("domain"):
        items = [i for i in items if i["domain"] == opts["domain"]]
    emit({"count": len(items), "items": items}, opts["format"])


def cmd_catalog_show(opts):
    cat = load_catalog()
    key = opts.get("module")
    if not key:
        fail(EXIT_USAGE, "invalid_argument", "缺少 --module")
    hit = None
    for m in cat["modules"]:
        if key in (m["id"], m["name"]):
            hit = m
            break
    if hit is None:
        fail(EXIT_NOT_FOUND, "not_found", "未收录该模块：%s" % key,
             "可先用 catalog list 查询可用模块")
    hit = dict(hit)
    hit["resolved"] = []
    for p in hit["paths"]:
        rp = os.path.expanduser(p) if p.startswith("~") else p
        hit["resolved"].append({
            "path": rp,
            "exists": os.path.exists(rp),
            "size": human(du_bytes(rp)) if os.path.exists(rp) else "-",
        })
    emit(hit, opts["format"])


def _collect_sources(opts):
    """汇总 collect plan / run 的采集源"""
    cat = load_catalog()
    expand = os.path.expanduser("~")
    sources = []
    for m in cat["modules"]:
        for p in m["paths"]:
            rp = p.replace("~", expand) if p.startswith("~") else p
            if "*" in rp:
                matched = glob.glob(rp)
                if matched:
                    sources.append({"path": rp, "module": m["id"],
                                    "exists": True, "size": human(du_bytes(matched[0]))})
                continue
            if os.path.exists(rp):
                sources.append({"path": rp, "module": m["id"], "exists": True,
                                "size": human(du_bytes(rp))})
    return sources


def cmd_collect_plan(opts):
    sources = _collect_sources(opts)
    missing = [s["path"] for s in sources if not s["exists"]]
    total = 0
    for s in sources:
        rp = s["path"]
        if os.path.exists(rp):
            total += du_bytes(rp)
    emit({
        "sources": len(sources),
        "total_size": human(total),
        "note": "以上为可用来源体积；实际复制受单文件与整包上限约束",
        "warnings": [] if IS_ROOT else ["未以管理员身份运行，部分系统日志将无法读取"],
        "items": sources,
    }, opts["format"])


def cmd_collect_run(opts):
    out = check_out_path(opts.get("out"))
    since = check_time(opts.get("since"), "--since")
    until = check_time(opts.get("until"), "--until")
    profile = opts.get("profile") or "full"
    ts = time.strftime("%Y%m%d_%H%M%S")
    dest = os.path.join(out, "collect_%s_%s" % (os.uname()[1], ts))

    changes = ["在 %s 创建采集目录" % dest,
               "复制系统与用户级日志副本（按绝对路径镜像）",
               "生成分域摘要与故障时间窗切片"]
    risks = ["读取日志可能包含业务数据，请按现场保密要求留存",
             "目标目录将新增文件，不修改被采集对象"]
    if os.path.exists(dest):
        handler = opts.get("if_exists") or "error"
        if handler == "error":
            fail(EXIT_CONFLICT, "conflict", "目标目录已存在：%s" % dest,
                 "可使用 --if-exists=skip 或 --if-exists=force")
        if handler == "skip":
            emit({"skipped": True, "out": dest, "reason": "already_exists"})
            return EXIT_OK

    if opts.get("dry_run"):
        extra = {"sources": len(_collect_sources(opts)),
                 "estimated_size": human(sum(du_bytes(s["path"]) for s in _collect_sources(opts)
                                             if os.path.exists(s["path"])))}
        return dry_run_result(dest, changes, risks, True, extra)

    if not IS_ROOT and not opts.get("yes"):
        log("提示：未以管理员身份运行，部分系统日志将缺失（可加 --yes 跳过该提示继续）")

    script = os.path.join(SCRIPT_DIR, "collect_logs.sh")
    if not os.path.isfile(script):
        fail(EXIT_NOT_FOUND, "not_found", "采集脚本缺失：%s" % script,
             "技能安装可能不完整，请重新安装")

    cmd = ["bash", script, "-o", out, "--exact-out", dest]
    if since:
        cmd += ["--since", since]
    if until:
        cmd += ["--until", until]
    env = dict(os.environ)
    if profile == "lite":
        env["LITE"] = "1"
    if opts.get("max_file_mb"):
        env["SIZE_LIMIT_MB"] = str(opts["max_file_mb"])
    if opts.get("max_total_mb"):
        env["MAX_TOTAL_MB"] = str(opts["max_total_mb"])

    log("开始采集，输出目录 %s" % dest)
    try:
        p = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        out_b, err_b = p.communicate()
    except OSError as e:
        fail(EXIT_ERROR, "internal_error", "采集脚本无法执行：%s" % e)
    for line in err_b.decode("utf-8", "replace").splitlines():
        log(line)
    if p.returncode != 0:
        fail(EXIT_ERROR, "collect_failed",
             "采集失败，脚本退出码 %d" % p.returncode,
             "请查看采集目录内 _run.log", retryable=True)

    tar = dest + ".tar.gz"
    emit({
        "out": dest,
        "archive": tar if os.path.isfile(tar) else "",
        "size": human(du_bytes(dest)),
        "sections": sorted([os.path.basename(f) for f in glob.glob(os.path.join(dest, "*.txt"))]),
        "warnings": [] if IS_ROOT else ["未以管理员身份运行，部分系统日志未采到"],
    }, opts["format"])
    return EXIT_OK


def _parse_kdump_id(ts):
    try:
        st = time.strptime(ts, "%Y%m%d%H%M%S")
        return time.strftime("%Y-%m-%d %H:%M:%S", st)
    except ValueError:
        return ""


def cmd_crash_list(opts):
    if not os.path.isdir(MINI_KDUMP_DIR):
        emit({"count": 0, "items": [],
              "notice": "未发现转储目录 %s（该机型可能未启用，或尚无异常转储）" % MINI_KDUMP_DIR},
             opts["format"])
        return EXIT_OK
    items = []
    for name in sorted(os.listdir(MINI_KDUMP_DIR)):
        d = os.path.join(MINI_KDUMP_DIR, name)
        if not os.path.isdir(d):
            continue
        files = []
        for f in sorted(os.listdir(d)):
            fp = os.path.join(d, f)
            if os.path.isfile(fp):
                files.append({"type": ("内核信息" if f == "dmesg" else
                                       "设备寄存器信息" if f == "dev_reg_info" else "其他"),
                              "name": f, "path": fp, "size": human(du_bytes(fp))})
        items.append({"id": name, "time": _parse_kdump_id(name),
                      "level": "错误", "path": d, "files": files})
    emit({"count": len(items), "items": items}, opts["format"])


def cmd_crash_show(opts):
    cid = opts.get("id")
    if not cid:
        fail(EXIT_USAGE, "invalid_argument", "缺少 --id（可用 crash list 查看）")
    d = os.path.join(MINI_KDUMP_DIR, cid)
    if not os.path.isdir(d):
        fail(EXIT_NOT_FOUND, "not_found", "未找到转储：%s" % d,
             "请先用 crash list 确认可用转储编号")
    detail = {"id": cid, "time": _parse_kdump_id(cid), "path": d, "files": {}}
    for f in ("dmesg", "dev_reg_info"):
        fp = os.path.join(d, f)
        if not os.path.isfile(fp):
            continue
        try:
            with open(fp, "r", errors="replace") as fh:
                lines = fh.read().splitlines()
        except OSError as e:
            fail(EXIT_PERMISSION, "permission_denied", "无法读取 %s：%s" % (fp, e),
                 "请以管理员身份重试", retryable=True)
        pat = re.compile(r"Oops|BUG:|kernel panic|Call Trace|hung task|soft lockup|"
                         r"Hard LOCKUP|general protection|Unable to handle|Out of memory")
        hits = [ln for ln in lines if pat.search(ln)]
        detail["files"][f] = {
            "path": fp,
            "lines": len(lines),
            "size": human(du_bytes(fp)),
            "highlights": hits[:50],
            "highlight_count": len(hits),
            "head": lines[:20],
        }
    if not opts.get("full"):
        for f in detail["files"].values():
            f.pop("head", None)
    emit(detail, opts["format"])


def cmd_core_list(opts):
    items = []
    coredumpctl = which("coredumpctl")
    if coredumpctl:
        rc, out, _err = run([coredumpctl, "list", "--no-pager"], timeout=60)
        if rc == 0:
            for ln in out.splitlines()[1:]:
                if ln.strip():
                    items.append({"id": ln.split()[4] if len(ln.split()) > 4 else "",
                                  "raw": ln.strip()})
    crash = "/var/crash"
    crash_files = []
    if os.path.isdir(crash):
        for f in sorted(os.listdir(crash)):
            fp = os.path.join(crash, f)
            if os.path.isfile(fp) and ("core" in f or f.endswith(".crash")):
                crash_files.append({"path": fp, "size": human(du_bytes(fp))})
    emit({"count": len(items), "coredumpctl": items,
          "crash_files": crash_files,
          "core_pattern": open("/proc/sys/kernel/core_pattern").read().strip()
          if os.path.isfile("/proc/sys/kernel/core_pattern") else ""},
         opts["format"])


def cmd_core_backtrace(opts):
    pid = opts.get("pid")
    if not pid:
        fail(EXIT_USAGE, "invalid_argument", "缺少 --pid")
    prog = opts.get("program")
    coredumpctl = which("coredumpctl")
    gdb = which("gdb")
    if not coredumpctl:
        fail(EXIT_NOT_FOUND, "not_found", "未安装 coredumpctl，无法导出调用栈",
             "请先安装系统核心转储组件")
    if not gdb:
        fail(EXIT_NOT_FOUND, "not_found", "未安装 gdb，无法解析调用栈",
             "请先安装 gdb")
    if opts.get("dry_run"):
        return dry_run_result("core:%s" % pid, ["调用 coredumpctl 导出 %s 的崩溃记录" % pid],
                              ["仅读取，不修改系统"], True)
    cmd = [coredumpctl, "info", prog] if prog else [coredumpctl, "info", str(pid)]
    rc, out, err = run(cmd, timeout=120)
    if rc != 0:
        fail(EXIT_NOT_FOUND, "not_found", "未找到对应核心转储：%s" % (err.strip() or pid),
             "可先用 core list 查看可用记录")
    keep = re.compile(r"PID|Signal|Command Line|Executable|Stack trace|#[0-9]+|"
                      r"Core was generated|Program terminated")
    frames = [ln for ln in out.splitlines() if keep.search(ln)]
    emit({"pid": pid, "program": prog or "", "frames": frames[:200],
          "frame_count": len(frames)}, opts["format"])


NET_FILTERS = {
    "auth": "port 30233 or port 30234 or port 1812 or port 1813 or port 1645 or port 1646 "
            "or port 389 or port 636 or port 88 or port 445 or ether proto 0x888e",
    "infra": "port 53 or port 67 or port 68 or icmp",
    "all": "",
}

CAPTURE_STATE = "net-capture.json"


def cmd_net_capture_start(opts):
    flt = opts.get("filter") or "auth"
    if flt not in NET_FILTERS:
        fail(EXIT_USAGE, "invalid_argument",
             "--filter 仅支持：%s" % ", ".join(sorted(NET_FILTERS)))
    out = check_out_path(opts.get("out"))
    tcpdump = which("tcpdump")
    pcap = os.path.join(out, "capture_%s_%s.pcap" % (flt, time.strftime("%Y%m%d_%H%M%S")))
    changes = ["在 %s 写入抓包文件" % pcap, "按预设过滤条件捕获网络报文"]
    risks = ["报文含网络层数据，请按保密要求留存",
             "长时间抓包会持续占用磁盘"]
    if opts.get("dry_run"):
        preflight = []
        if not tcpdump:
            preflight.append("未安装抓包工具 tcpdump，实际执行前需先安装")
        return dry_run_result("tcpdump:%s" % flt, changes, risks, True,
                              {"filter": NET_FILTERS[flt], "pcap": pcap,
                               "preflight": preflight})
    if not tcpdump:
        fail(EXIT_NOT_FOUND, "not_found", "未安装抓包工具 tcpdump",
             "请先安装抓包工具后重试")
    if read_state(CAPTURE_STATE):
        fail(EXIT_CONFLICT, "conflict", "已有正在进行的抓包任务",
             "可先执行 capture stop，或查看 capture status")
    require_root("启动抓包")
    if not os.path.isdir(out):
        os.makedirs(out)
    cmd = [tcpdump, "-i", "any", "-s", "0", "-w", pcap]
    if NET_FILTERS[flt]:
        cmd.append(NET_FILTERS[flt])
    log("启动抓包：%s" % " ".join(cmd))
    with open(os.devnull, "wb") as devnull:
        proc = subprocess.Popen(cmd, stdout=devnull, stderr=devnull,
                                start_new_session=True)
    time.sleep(1)
    if proc.poll() is not None:
        fail(EXIT_ERROR, "internal_error", "抓包进程启动后立即退出",
             "请确认过滤条件与服务端口是否正确", retryable=True)
    write_state(CAPTURE_STATE, {"pid": proc.pid, "pcap": pcap, "filter": flt,
                                "started": time.strftime("%Y-%m-%d %H:%M:%S")})
    emit({"capture": pcap, "pid": proc.pid, "filter": flt, "status": "running"})
    return EXIT_OK


def cmd_net_capture_stop(opts):
    st = read_state(CAPTURE_STATE)
    if not st:
        fail(EXIT_NOT_FOUND, "not_found", "当前没有进行中的抓包任务")
    if opts.get("dry_run"):
        return dry_run_result("capture:%s" % st["pcap"], ["停止抓包进程并落盘"], [], True)
    try:
        os.kill(st["pid"], signal.SIGINT)
        for _ in range(20):
            time.sleep(0.3)
            try:
                os.kill(st["pid"], 0)
            except OSError:
                break
    except OSError as e:
        clear_state(CAPTURE_STATE)
        fail(EXIT_ERROR, "internal_error", "停止抓包进程失败：%s" % e, retryable=True)
    clear_state(CAPTURE_STATE)
    size = du_bytes(st["pcap"]) if os.path.isfile(st["pcap"]) else 0
    emit({"pcap": st["pcap"], "size": human(size), "status": "stopped",
          "started": st.get("started", "")})
    return EXIT_OK


def cmd_net_capture_status(opts):
    st = read_state(CAPTURE_STATE)
    if not st:
        emit({"status": "idle"})
        return EXIT_OK
    alive = True
    try:
        os.kill(st["pid"], 0)
    except OSError:
        alive = False
    emit({"status": "running" if alive else "stale", "pcap": st["pcap"],
          "pid": st["pid"], "filter": st.get("filter"), "started": st.get("started")})
    return EXIT_OK


# PAM 调试目标：name -> (文件, 类型, 需要加 debug 的模块关键字)
AUTH_TARGETS = {
    "common-auth": ("/etc/pam.d/common-auth", "pam", ["pam_unix.so"]),
    "lightdm": ("/etc/pam.d/lightdm", "pam", ["pam_unix.so", "pam_krb5.so", "pam_sss.so"]),
    "screensaver": ("/etc/pam.d/ukui-screensaver-qt", "pam",
                    ["pam_unix.so", "pam_krb5.so", "pam_sss.so"]),
    "login": ("/etc/pam.d/login", "pam", ["pam_unix.so"]),
    "sshd": ("/etc/ssh/sshd_config", "sshd", []),
}


def _auth_state_name(target):
    return "auth-debug-%s.json" % target


def cmd_auth_debug_enable(opts):
    targets = _auth_targets(opts)
    if opts.get("dry_run"):
        changes = []
        for t in targets:
            path = AUTH_TARGETS[t][0]
            changes.append("备份 %s 并追加调试开关（定位后可用 debug disable 还原）" % path)
        return dry_run_result("、".join(targets), changes,
                              ["调试日志会记录用户名/认证过程等敏感信息，定位完成后必须及时关闭"],
                              True)
    require_root("开启登录认证调试")
    done = []
    for t in targets:
        path, kind, mods = AUTH_TARGETS[t]
        if not os.path.isfile(path):
            fail(EXIT_NOT_FOUND, "not_found", "配置文件不存在：%s" % path)
        st = read_state(_auth_state_name(t))
        if st and st.get("enabled"):
            if (opts.get("if_exists") or "error") == "error":
                fail(EXIT_CONFLICT, "conflict", "%s 已处于调试状态" % t,
                     "可先执行 debug disable --target %s，或使用 --if-exists=skip" % t)
            done.append({"target": t, "status": "already_enabled", "file": path})
            continue
        backup = os.path.join(STATE_DIR, "backup-%s-%s" % (t, time.strftime("%Y%m%d_%H%M%S")))
        if not os.path.isdir(STATE_DIR):
            os.makedirs(STATE_DIR, 0o700)
        with open(path, "r", errors="replace") as fh:
            orig = fh.read()
        with open(backup, "w") as fh:
            fh.write(orig)
        if kind == "pam":
            new = _pam_add_debug(orig, mods)
        else:
            new = _sshd_add_debug(orig)
        with open(path, "w") as fh:
            fh.write(new)
        write_state(_auth_state_name(t), {"enabled": True, "file": path,
                                          "backup": backup,
                                          "time": time.strftime("%Y-%m-%d %H:%M:%S")})
        done.append({"target": t, "status": "enabled", "file": path, "backup": backup})
        log("已开启 %s 调试，备份：%s" % (path, backup))
    emit({"targets": done,
          "reminder": "定位完成后请执行 auth debug disable 还原配置，并清理调试日志"},
         opts["format"])
    return EXIT_OK


def cmd_auth_debug_disable(opts):
    targets = _auth_targets(opts)
    if opts.get("dry_run"):
        return dry_run_result("、".join(targets), ["从备份还原登录认证配置"], [], True)
    require_root("关闭登录认证调试")
    done = []
    for t in targets:
        st = read_state(_auth_state_name(t))
        if not st or not st.get("enabled"):
            done.append({"target": t, "status": "not_enabled"})
            continue
        backup = st.get("backup")
        if not backup or not os.path.isfile(backup):
            fail(EXIT_NOT_FOUND, "not_found", "备份文件缺失，无法自动还原：%s" % backup,
                 "请人工比对并还原 %s" % st.get("file"))
        with open(backup, "r", errors="replace") as fh:
            orig = fh.read()
        with open(st["file"], "w") as fh:
            fh.write(orig)
        clear_state(_auth_state_name(t))
        done.append({"target": t, "status": "restored", "file": st["file"],
                     "backup": backup})
        log("已还原 %s" % st["file"])
    emit({"targets": done}, opts["format"])
    return EXIT_OK


def cmd_auth_debug_status(opts):
    items = []
    for t in sorted(AUTH_TARGETS):
        st = read_state(_auth_state_name(t)) or {}
        items.append({"target": t, "file": AUTH_TARGETS[t][0],
                      "enabled": bool(st.get("enabled")),
                      "backup": st.get("backup", ""), "since": st.get("time", "")})
    emit({"count": len(items), "items": items}, opts["format"])


def _auth_targets(opts):
    raw = opts.get("target") or "all"
    if raw == "all":
        return [t for t in AUTH_TARGETS if t != "sshd"]
    if raw not in AUTH_TARGETS:
        fail(EXIT_USAGE, "invalid_argument",
             "--target 仅支持：all、%s" % "、".join(sorted(AUTH_TARGETS)))
    return [raw]


def _pam_add_debug(text, mods):
    out = []
    for ln in text.splitlines():
        s = ln.strip()
        if s and not s.startswith("#") and " debug" not in s:
            for m in mods:
                if m in s:
                    ln = ln.rstrip() + " debug"
                    break
        out.append(ln)
    return "\n".join(out) + "\n"


def _sshd_add_debug(text):
    out = []
    done = False
    for ln in text.splitlines():
        if re.match(r"^\s*LogLevel\s", ln) and not done:
            ln = "LogLevel DEBUG3"
            done = True
        out.append(ln)
    if not done:
        out.append("LogLevel DEBUG3")
    return "\n".join(out) + "\n"


def cmd_bundle_pack(opts):
    d = opts.get("dir")
    if not d:
        fail(EXIT_USAGE, "invalid_argument", "缺少 --dir")
    ap = check_out_path(d)
    if not os.path.isdir(ap):
        fail(EXIT_NOT_FOUND, "not_found", "目录不存在：%s" % ap)
    tar = ap.rstrip("/") + ".tar.gz"
    if os.path.isfile(tar) and (opts.get("if_exists") or "error") == "error":
        fail(EXIT_CONFLICT, "conflict", "打包文件已存在：%s" % tar,
             "可使用 --if-exists=force 覆盖")
    if opts.get("dry_run"):
        return dry_run_result(tar, ["将 %s 打包为 %s" % (ap, tar)],
                              ["仅新增归档文件，不修改原目录"], True,
                              {"dir": ap, "archive": tar, "size": human(du_bytes(ap))})
    cmd = ["tar", "-czf", tar, "-C", os.path.dirname(ap), os.path.basename(ap)]
    rc, _o, e = run(cmd, timeout=1800)
    if rc != 0:
        fail(EXIT_ERROR, "internal_error", "打包失败：%s" % e.strip(), retryable=True)
    emit({"dir": ap, "archive": tar, "size": human(du_bytes(tar))})
    return EXIT_OK


def cmd_doc_extract(opts):
    f = check_file_path(opts.get("file"))
    ext = os.path.splitext(f)[1].lower()
    if ext in (".wps", ".doc", ".xls", ".ppt"):
        tool = os.path.join(SCRIPT_DIR, "ole_doc_text.py")
    elif ext in (".docx", ".xlsx", ".pptx"):
        tool = os.path.join(SCRIPT_DIR, "docx_text.py")
    else:
        fail(EXIT_USAGE, "invalid_argument", "暂不支持的文件类型：%s" % ext,
             "支持 .wps/.doc（旧版办公文档）与 .docx 等新版办公文档")
    if not os.path.isfile(tool):
        fail(EXIT_NOT_FOUND, "not_found", "文本解析工具缺失：%s" % tool)
    rc, out, err = run([sys.executable, tool, f], timeout=180)
    if rc != 0:
        fail(EXIT_ERROR, "internal_error", "文档解析失败：%s" % (err.strip() or rc))
    lines = out.splitlines()
    emit({"file": f, "lines": len(lines), "text": out}, opts["format"])
    return EXIT_OK


# --------------------------------------------------------------------------- #
# 命令表 / 帮助 / schema
# --------------------------------------------------------------------------- #
COMMANDS = [
    {
        "noun": "collect", "verb": "run", "summary": "采集本机问题现场日志并打包",
        "mutating": True,
        "args": {
            "out": {"required": True, "desc": "输出目录（父目录）"},
            "since": {"desc": "故障起始时间，格式 'YYYY-MM-DD HH:MM'"},
            "until": {"desc": "故障结束时间，格式 'YYYY-MM-DD HH:MM'"},
            "profile": {"enum": ["full", "lite"], "default": "full",
                        "desc": "采集档位：full 完整 / lite 快速"},
            "max-file-mb": {"type": "int", "desc": "单文件上限（MB），超出只保留尾部"},
            "max-total-mb": {"type": "int", "desc": "整包体积上限（MB），超出跳过后续大目录"},
            "if-exists": {"enum": ["error", "skip", "force"], "default": "error",
                          "desc": "目标已存在时的处理方式"},
            "dry-run": {"type": "flag", "desc": "只返回将要执行的动作，不落盘"},
            "yes": {"type": "flag", "desc": "非交互确认（自动化环境使用）"},
        },
        "handler": cmd_collect_run,
    },
    {
        "noun": "collect", "verb": "plan", "summary": "查看将要采集的日志来源与体积（只读）",
        "args": {
            "domain": {"desc": "只看某个问题域，如 桌面、网络、登录、死机"},
        },
        "handler": cmd_collect_plan,
    },
    {
        "noun": "catalog", "verb": "list", "summary": "列出已收录的模块与对应日志位置",
        "args": {"domain": {"desc": "按问题域过滤，如 桌面、网络、登录、死机"}},
        "handler": cmd_catalog_list,
    },
    {
        "noun": "catalog", "verb": "show", "summary": "查看某个模块的日志位置与是否存在",
        "args": {"module": {"required": True, "desc": "模块标识，如 ukui-panel、lightdm、mini-kdump"}},
        "handler": cmd_catalog_show,
    },
    {
        "noun": "crash", "verb": "list", "summary": "列出系统异常转储记录（只读）",
        "args": {},
        "handler": cmd_crash_list,
    },
    {
        "noun": "crash", "verb": "show", "summary": "查看指定转储的内核信息与设备寄存器快照",
        "args": {
            "id": {"required": True, "desc": "转储编号（目录名时间戳），可用 crash list 获取"},
            "full": {"type": "flag", "desc": "额外返回文件头部内容"},
        },
        "handler": cmd_crash_show,
    },
    {
        "noun": "core", "verb": "list", "summary": "列出应用崩溃记录与核心转储位置（只读）",
        "args": {},
        "handler": cmd_core_list,
    },
    {
        "noun": "core", "verb": "backtrace", "summary": "导出指定进程崩溃时的调用栈",
        "mutating": True,
        "args": {
            "pid": {"required": True, "desc": "进程号"},
            "program": {"desc": "程序名（与 --pid 二选一，按程序名查找更稳）"},
            "dry-run": {"type": "flag", "desc": "只返回将要执行的动作"},
        },
        "handler": cmd_core_backtrace,
    },
    {
        "noun": "capture", "verb": "start", "summary": "启动网络抓包（写文件，需管理员）",
        "mutating": True,
        "args": {
            "filter": {"enum": ["auth", "infra", "all"], "default": "auth",
                       "desc": "抓包范围：auth 认证与准入 / infra 域名与地址分配 / all 全部"},
            "out": {"required": True, "desc": "抓包文件输出目录"},
            "dry-run": {"type": "flag", "desc": "只返回将要执行的动作"},
        },
        "handler": cmd_net_capture_start,
    },
    {
        "noun": "capture", "verb": "stop", "summary": "停止抓包并落盘",
        "mutating": True,
        "args": {"dry-run": {"type": "flag", "desc": "只返回将要执行的动作"}},
        "handler": cmd_net_capture_stop,
    },
    {
        "noun": "capture", "verb": "status", "summary": "查看抓包任务状态（只读）",
        "args": {},
        "handler": cmd_net_capture_status,
    },
    {
        "noun": "debug", "verb": "enable", "summary": "临时开启登录认证详细日志（需管理员）",
        "mutating": True,
        "args": {
            "target": {"enum": ["all", "common-auth", "lightdm", "screensaver", "login", "sshd"],
                       "default": "all", "desc": "调试对象，all 表示各登录入口共用的认证配置"},
            "if-exists": {"enum": ["error", "skip", "force"], "default": "error",
                          "desc": "已开启时的处理方式"},
            "dry-run": {"type": "flag", "desc": "只返回将要执行的动作"},
        },
        "handler": cmd_auth_debug_enable,
    },
    {
        "noun": "debug", "verb": "disable", "summary": "还原登录认证配置（定位完成后必须执行）",
        "mutating": True,
        "args": {
            "target": {"enum": ["all", "common-auth", "lightdm", "screensaver", "login", "sshd"],
                       "default": "all", "desc": "调试对象"},
            "dry-run": {"type": "flag", "desc": "只返回将要执行的动作"},
        },
        "handler": cmd_auth_debug_disable,
    },
    {
        "noun": "debug", "verb": "status", "summary": "查看登录认证调试开关状态（只读）",
        "args": {},
        "handler": cmd_auth_debug_status,
    },
    {
        "noun": "bundle", "verb": "pack", "summary": "把采集目录打包为单一归档文件",
        "mutating": True,
        "args": {
            "dir": {"required": True, "desc": "待打包的采集目录"},
            "if-exists": {"enum": ["error", "force"], "default": "error",
                          "desc": "归档已存在时的处理方式"},
            "dry-run": {"type": "flag", "desc": "只返回将要执行的动作"},
        },
        "handler": cmd_bundle_pack,
    },
    {
        "noun": "doc", "verb": "extract", "summary": "读取办公文档附件并输出纯文本（只读）",
        "args": {"file": {"required": True, "desc": "文档绝对路径（.wps/.doc/.docx 等）"}},
        "handler": cmd_doc_extract,
    },
]

GLOBAL_ARGS = {"format": {"enum": ["json", "table", "ndjson"], "default": "json",
                          "desc": "输出格式，默认 json"},
               "json": {"type": "flag", "desc": "--format json 的简写"}}


def find_command(noun, verb):
    for c in COMMANDS:
        if c["noun"] == noun and c["verb"] == verb:
            return c
    return None


def print_help():
    lines = ["本机日志采集分析 —— 可用命令：", ""]
    last = None
    for c in COMMANDS:
        if c["noun"] != last:
            lines.append("%s" % c["noun"])
            last = c["noun"]
        lines.append("  %s %s    %s" % (c["noun"], c["verb"], c["summary"]))
    lines += ["", "查看单个命令： help <名词> <动词>", "查看机器可读定义： schema --all",
              "", "常用示例：",
              "  local-log-collect collect plan",
              "  local-log-collect collect run --out /tmp/case123 --since '2026-09-17 18:30'",
              "  local-log-collect crash list",
              "  local-log-collect catalog show --module ukui-panel"]
    sys.stdout.write("\n".join(lines) + "\n")


def print_command_help(c):
    lines = ["用法： local-log-collect %s %s [选项]" % (c["noun"], c["verb"]),
             "", c["summary"], "", "选项："]
    for name, spec in c["args"].items():
        flag = "--%s" % name
        req = "必填" if spec.get("required") else "可选"
        extra = ""
        if spec.get("enum"):
            extra = "，取值：%s" % "、".join(spec["enum"])
        if spec.get("default") is not None:
            extra += "，默认 %s" % spec["default"]
        lines.append("  %-16s %s（%s%s）" % (flag, spec.get("desc", ""), req, extra))
    lines.append("  %-16s %s" % ("--format <fmt>", "输出格式，默认 json"))
    lines.append("  %-16s %s" % ("--help", "查看本帮助"))
    lines.append("")
    lines.append("所有写操作均支持 --dry-run（成功返回退出码 10）。")
    sys.stdout.write("\n".join(lines) + "\n")


def print_schema(noun=None, verb=None):
    items = []
    for c in COMMANDS:
        if noun and c["noun"] != noun:
            continue
        if verb and c["verb"] != verb:
            continue
        args = []
        for name, spec in c["args"].items():
            args.append({"name": name, "type": spec.get("type", "string"),
                         "required": bool(spec.get("required")),
                         "default": spec.get("default"),
                         "enum": spec.get("enum", []),
                         "description": spec.get("desc", "")})
        args.append({"name": "format", "type": "string", "required": False,
                     "default": "json", "enum": ["json", "table", "ndjson"],
                     "description": "输出格式"})
        items.append({"command": "%s %s" % (c["noun"], c["verb"]),
                      "summary": c["summary"],
                      "mutating": bool(c.get("mutating")),
                      "supports_dry_run": bool(c.get("mutating")),
                      "arguments": args,
                      "example": "local-log-collect %s %s" % (c["noun"], c["verb"])})
    if not items:
        fail(EXIT_NOT_FOUND, "not_found", "未找到匹配的命令")
    emit({"count": len(items), "items": items})


# --------------------------------------------------------------------------- #
# 参数解析
# --------------------------------------------------------------------------- #
def parse_args(argv, spec_args, ctx=""):
    """把 --k v / --flag 解析为 dict，并做枚举与类型校验"""
    opts = {}
    i = 0
    known = set(spec_args.keys()) | {"format", "json", "help"}
    while i < len(argv):
        a = argv[i]
        if not a.startswith("--"):
            fail(EXIT_USAGE, "invalid_argument", "无法识别的参数：%s" % a,
                 "长选项请使用 --小写中横线 形式")
        name = a[2:]
        if name not in known:
            fail(EXIT_USAGE, "invalid_argument", "未知选项：--%s" % name,
                 "可执行 help %s 查看支持的选项" % ctx)
        spec = spec_args.get(name) or GLOBAL_ARGS.get(name, {})
        key = name.replace("-", "_")
        if spec.get("type") == "flag":
            opts[key] = True
            i += 1
            continue
        if i + 1 >= len(argv):
            fail(EXIT_USAGE, "invalid_argument", "选项 --%s 缺少取值" % name)
        value = argv[i + 1]
        if value.startswith("--") and spec.get("required"):
            fail(EXIT_USAGE, "invalid_argument", "选项 --%s 缺少取值" % name)
        if spec.get("enum") and value not in spec["enum"]:
            fail(EXIT_USAGE, "invalid_argument",
                 "--%s 取值非法：%s" % (name, value),
                 "允许的取值：%s" % "、".join(spec["enum"]))
        if spec.get("type") == "int":
            try:
                value = int(value)
            except ValueError:
                fail(EXIT_USAGE, "invalid_argument", "--%s 需要整数" % name)
        opts[key] = value
        i += 2
    for name, spec in spec_args.items():
        if spec.get("required") and not opts.get(name.replace("-", "_")):
            fail(EXIT_USAGE, "invalid_argument", "缺少必填选项 --%s" % name,
                 "可用 help 查看用法")
    if opts.get("json"):
        opts["format"] = "json"
    opts.setdefault("format", "json")
    if opts["format"] not in ("json", "table", "ndjson"):
        fail(EXIT_USAGE, "invalid_argument", "--format 仅支持 json、table、ndjson")
    if opts.get("yes"):
        os.environ["LOCAL_LOG_COLLECT_YES"] = "1"
    return opts


def main(argv):
    if not argv or argv[0] in ("-h", "--help", "help") and len(argv) == 1:
        print_help()
        return EXIT_OK

    if argv[0] == "help":
        if len(argv) == 1:
            print_help()
            return EXIT_OK
        noun = argv[1]
        verb = argv[2] if len(argv) > 2 else None
        if verb is None:
            rows = [c for c in COMMANDS if c["noun"] == noun]
            if not rows:
                fail(EXIT_NOT_FOUND, "not_found", "未找到命令组：%s" % noun)
            sys.stdout.write("\n".join("%s %s    %s" % (c["noun"], c["verb"], c["summary"])
                                       for c in rows) + "\n")
            return EXIT_OK
        c = find_command(noun, verb)
        if not c:
            fail(EXIT_NOT_FOUND, "not_found", "未找到命令：%s %s" % (noun, verb))
        print_command_help(c)
        return EXIT_OK

    if argv[0] == "schema":
        noun = argv[1] if len(argv) > 1 and argv[1] != "--all" else None
        verb = argv[2] if len(argv) > 2 else None
        print_schema(noun, verb)
        return EXIT_OK

    if argv[0] == "--version":
        sys.stdout.write("1.0.0\n")
        return EXIT_OK

    if len(argv) < 2:
        fail(EXIT_USAGE, "invalid_argument", "缺少动词，例如 collect run",
             "执行 local-log-collect --help 查看全部命令")

    noun, verb = argv[0], argv[1]
    c = find_command(noun, verb)
    if not c:
        fail(EXIT_NOT_FOUND, "not_found", "未找到命令：%s %s" % (noun, verb),
             "执行 local-log-collect --help 查看全部命令")
    rest = argv[2:]
    if "--help" in rest or "-h" in rest:
        print_command_help(c)
        return EXIT_OK
    opts = parse_args(rest, c["args"], "%s %s" % (noun, verb))
    return c["handler"](opts)


if __name__ == "__main__":
    try:
        sys.exit(main(sys.argv[1:]))
    except CliError as e:
        sys.stderr.flush()
        sys.exit(report_error(e, "json"))
    except KeyboardInterrupt:
        log("已中断")
        sys.exit(EXIT_ERROR)
    except Exception as exc:  # 兜底：不向用户抛栈
        sys.exit(report_error(CliError(EXIT_ERROR, "internal_error",
                                       "内部错误：%s" % exc, retryable=False), "json"))
