#!/usr/bin/env python3
"""
Theseus E2E 功能测试

测试方式：
  1. 启动一个全新的 theseus server（随机端口，临时数据库）
  2. 用 theseus CLI 逐个测试每个功能
  3. 断言输出符合预期
  4. 测试完自动清理

使用：
  python3 tests/e2e_test.py

环境要求：
  - theseus 已编译（cargo build -p theseus）
  - 无需外部依赖（纯 stdlib）
"""

import json
import os
import re
import signal
import socket
import subprocess
import sys
import tempfile
import time
from urllib import error as urlerror
from urllib import request as urlrequest
import uuid
from pathlib import Path

# ── 配置 ──

ROOT = Path(__file__).resolve().parent.parent
THESEUS_BIN = ROOT / "target" / "debug" / "theseus"
DB_FILE = ROOT / "theseus.db"

# ── 工具函数 ──

class TestContext:
    """管理测试用的 server 进程和临时数据库"""

    def __init__(self):
        self.port = self._find_free_port()
        self.server_proc = None
        self.tmp_db = None
        self.original_db_files = []
        self.run_tag = uuid.uuid4().hex[:8]

    def _find_free_port(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.bind(("", 0))
            return s.getsockname()[1]

    def start(self):
        """备份原数据库，启动测试 server"""
        # Kill any existing theseus server
        subprocess.run(["pkill", "-f", "theseus serve"], capture_output=True)
        time.sleep(1)

        # 备份原数据库。SQLite WAL 模式会留下 sidecar 文件；必须一起隔离，
        # 否则新建的测试数据库会从 stale WAL 恢复出旧项目，导致固定名称 409。
        self.tmp_db = Path(tempfile.mkdtemp(prefix="theseus-e2e-db-"))
        for db_part in self._db_files():
            if db_part.exists():
                backup_path = self.tmp_db / db_part.name
                db_part.rename(backup_path)
                self.original_db_files.append((db_part, backup_path))

        # 启动 server
        env = os.environ.copy()
        self.server_proc = subprocess.Popen(
            [str(THESEUS_BIN), "serve", "--port", str(self.port)],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            env=env,
        )
        # 等待 server 就绪
        for _ in range(20):
            try:
                urlrequest.urlopen(f"http://127.0.0.1:{self.port}/api/v1/projects", timeout=1)
                return
            except Exception:
                time.sleep(0.3)
        raise RuntimeError(f"Server failed to start on port {self.port}")

    def stop(self):
        """停止 server，恢复原数据库"""
        if self.server_proc:
            self.server_proc.send_signal(signal.SIGTERM)
            try:
                self.server_proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                self.server_proc.kill()
                self.server_proc.wait()

        # 清理测试数据库及 SQLite WAL/SHM sidecar 文件
        for db_part in self._db_files():
            if db_part.exists():
                db_part.unlink()

        # 恢复原数据库
        for original_path, backup_path in self.original_db_files:
            if backup_path.exists():
                backup_path.rename(original_path)

        # Restart original server if there was one
        if self.original_db_files:
            subprocess.Popen(
                [str(THESEUS_BIN), "serve", "--port", "3080"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
            )

        if self.tmp_db and self.tmp_db.exists():
            for child in list(self.tmp_db.iterdir()):
                if child.exists():
                    child.unlink()
            self.tmp_db.rmdir()

    def _db_files(self):
        return [DB_FILE, ROOT / "theseus.db-wal", ROOT / "theseus.db-shm"]

    @property
    def server_arg(self):
        return f"http://127.0.0.1:{self.port}"

    def project_name(self, base):
        return f"{base}-{self.run_tag}"


def run_cli(*args, expect_fail=False):
    """运行 theseus CLI 命令，返回 (exit_code, stdout, stderr)"""
    cmd = [str(THESEUS_BIN)] + list(args)
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    if not expect_fail and result.returncode != 0:
        print(f"  CLI FAILED: {' '.join(args)}")
        print(f"  stdout: {result.stdout[:500]}")
        print(f"  stderr: {result.stderr[:500]}")
    return result.returncode, result.stdout, result.stderr


def run_cli_json(*args, expect_fail=False):
    """运行 CLI 并解析 JSON 输出"""
    # 大多数命令的 --json 输出都是 ApiResponse<T> JSON
    args = maybe_add_json_flag(args)
    code, out, err = run_cli(*args, expect_fail=expect_fail)
    if code != 0:
        return None, code, out, err
    try:
        return json.loads(out), code, out, err
    except json.JSONDecodeError:
        return None, code, out, err


def json_data(data):
    return data["data"] if data and data.get("data") is not None else None


def maybe_add_json_flag(args):
    args = list(args)
    if "--json" in args or not args:
        return args

    command = args[0]
    action = None
    index = 1
    while index < len(args):
        arg = args[index]
        if arg in ("-s", "--server"):
            index += 2
            continue
        if arg.startswith("-"):
            index += 1
            continue
        action = arg
        break
    json_actions = {
        "project": {"create", "list", "analysis"},
        "node": {"add", "list"},
        "edge": {"add", "list"},
        "credential": {"add", "list"},
    }
    if action in json_actions.get(command, set()):
        args.append("--json")
    return args


def create_project_cli(server, name):
    """Create a project through CLI and return its full UUID."""
    data, code, *_ = run_cli_json("project", "-s", server, "create", name, "--json")
    ok(code == 0, f"create project '{name}'")
    ok(data and data.get("success") is True, "project create response success")
    created = json_data(data)
    return created["id"] if created else None


def create_node_cli(server, pid, label, *args):
    """Create a node through CLI and return its full UUID."""
    data, code, *_ = run_cli_json("node", "-s", server, "add", pid, label, *args, "--json")
    ok(code == 0, f"create node '{label}'")
    ok(data and data.get("success") is True, "node create response success")
    created = json_data(data)
    return created["id"] if created else None


# ── 测试计数 ──

passed = 0
failed = 0
current_test = ""


def test(name):
    global current_test
    current_test = name
    print(f"\n  TEST: {name}")


def ok(condition, detail=""):
    global passed, failed
    if condition:
        passed += 1
        print(f"    ✅ {detail}" if detail else "    ✅ pass")
    else:
        failed += 1
        print(f"    ❌ FAIL: {detail}")


# ── 测试用例 ──

def test_project_crud(ctx):
    test("project create / list / delete")
    s = ctx.server_arg
    name_a = ctx.project_name("测试项目A")
    name_b = ctx.project_name("测试项目B")

    # create
    data, code, *_ = run_cli_json("project", "-s", s, "create", name_a)
    ok(code == 0, "create project")
    ok(data and data.get("success") is True, "response success=true")
    project = json_data(data)
    ok(project and project["name"] == name_a, "name matches")
    pid = project["id"] if project else ""
    ok(len(pid) == 36, f"full UUID returned (len={len(pid)})")

    # list default (table format)
    code, out, _ = run_cli("project", "-s", s, "list")
    ok(code == 0, "list projects (table)")
    ok(name_a in out, "project name appears in table output")
    ok(re.search(rf"#1\s+\w{{8}}\s+{re.escape(name_a)}", out), "table format: #<n> <short-id> <name>")

    # list --json
    data, code, *_ = run_cli_json("project", "-s", s, "list", "--json")
    ok(code == 0, "list projects --json")
    ok(data and data.get("success") is True, "json response success")
    projects = json_data(data)
    ok(isinstance(projects, list) and len(projects) >= 1, "json data is array")

    # create second project
    data2, code, *_ = run_cli_json("project", "-s", s, "create", name_b)
    ok(code == 0, "create second project")
    project2 = json_data(data2)
    pid2 = project2["id"] if project2 else ""

    # list shows both
    code, out, _ = run_cli("project", "-s", s, "list")
    ok(name_a in out and name_b in out, "list shows both projects")

    # delete
    code, out, _ = run_cli("project", "-s", s, "delete", pid)
    ok(code == 0, "delete project")
    ok("deleted" in out, "delete output confirms deletion")

    # verify deleted
    code, out, _ = run_cli("project", "-s", s, "list")
    ok(name_a not in out, "deleted project not in list")
    ok(name_b in out, "other project still in list")

    return pid2


def test_project_uuid_prefix(ctx, pid):
    test("project UUID prefix matching")
    s = ctx.server_arg

    # 6-char prefix
    prefix6 = pid[:6]
    data, code, *_ = run_cli_json("project", "-s", s, "list", "--json")
    ok(code == 0, "list with --json for prefix test")
    # project delete with prefix
    code, out, _ = run_cli("project", "-s", s, "delete", prefix6)
    ok(code == 0, f"delete with 6-char prefix '{prefix6}'")
    ok("deleted" in out, "prefix delete output confirms deletion")

    # too short prefix
    code, _, err = run_cli("project", "-s", s, "delete", pid[:4], expect_fail=True)
    ok(code != 0, "reject prefix < 6 chars")
    ok("too short" in err, "error mentions 'too short'")


def test_node_crud_and_validation(ctx):
    test("node CRUD + validation rules")
    s = ctx.server_arg
    project_name = ctx.project_name("节点测试项目")

    # Create project first
    data, code, *_ = run_cli_json("project", "-s", s, "create", project_name)
    ok(code == 0, "create test project")
    project = json_data(data)
    pid = project["id"] if project else ""

    # --- FIRST node: no --after required ---
    data, code, *_ = run_cli_json("node", "-s", s, "add", pid, "入口节点", "-t", "objective",
                                   "--content", "目标是 http://10.0.0.1:8080")
    ok(code == 0, "first node: no --after required")
    ok(data and data.get("success") is True, "first node created successfully")
    node1 = json_data(data)
    node1_id = node1["id"] if node1 else ""

    # --- SECOND node: must have --after ---
    data, code, out, _ = run_cli_json("node", "-s", s, "add", pid, "孤立节点",
                                       "--content", "测试孤立节点", expect_fail=True)
    ok(code != 0, "isolated node rejected (no --after)")
    # Check it's our validation, not some other error
    ok("孤立节点" in out, "error message mentions '孤立节点'")

    # --- SECOND node with --after: should work ---
    data, code, *_ = run_cli_json("node", "-s", s, "add", pid, "服务发现", "-t", "service",
                                   "--after", node1_id, "--edge-label", "端口扫描",
                                   "--content", "发现 http://10.0.0.1:8080/admin")
    ok(code == 0, "node with --after succeeds")
    node2 = json_data(data)
    node2_id = node2["id"] if node2 else ""

    # --- Bare path URL rejected ---
    data, code, out, _ = run_cli_json("node", "-s", s, "add", pid, "裸路径节点",
                                       "--after", node2_id, "--edge-label", "test",
                                       "--content", "访问 /admin/login 发现后台",
                                       expect_fail=True)
    ok(code != 0, "bare path /admin/login rejected")
    ok("裸路径" in out, "error mentions '裸路径'")

    # --- Full URL accepted ---
    data, code, *_ = run_cli_json("node", "-s", s, "add", pid, "正常URL节点", "-t", "note",
                                   "--after", node2_id, "--edge-label", "访问",
                                   "--content", "访问 http://10.0.0.1:8080/admin/login 成功")
    ok(code == 0, "full URL accepted")
    node3 = json_data(data)
    node3_id = node3["id"] if node3 else ""

    # --- Node list table format ---
    code, out, _ = run_cli("node", "-s", s, "list", pid)
    ok(code == 0, "node list (table)")
    lines = [l for l in out.strip().split("\n") if l.strip() and not l.startswith("{")]
    ok(len(lines) >= 3, f"table shows >= 3 nodes (got {len(lines)})")
    # Check format: #N  short_id  label  verdict=  evidence=
    ok(re.search(r"#1\s+\w{8}\s+", lines[0]), "table has short ID")

    # --- Node list --json ---
    data, code, *_ = run_cli_json("node", "-s", s, "list", pid, "--json")
    ok(code == 0, "node list --json")
    nodes = json_data(data)
    ok(isinstance(nodes, list), "json array response")
    ok(isinstance(nodes, list) and len(nodes) == 3, "3 nodes in json")

    # --- Node get ---
    data, code, *_ = run_cli_json("node", "-s", s, "get", pid, node1_id)
    ok(code == 0, "node get")
    node = json_data(data)
    ok(node and node["label"] == "入口节点", "get returns correct node")

    # --- Node get with UUID prefix ---
    data, code, *_ = run_cli_json("node", "-s", s, "get", pid, node2_id[:8])
    ok(code == 0, "node get with 8-char prefix")
    node = json_data(data)
    ok(node and node["label"] == "服务发现", "prefix-resolved node correct")

    # --- Node update ---
    data, code, *_ = run_cli_json("node", "-s", s, "update", pid, node3_id,
                                   "--set-verdict", "success", "--set-evidence", "confirmed")
    ok(code == 0, "node update verdict+evidence")

    # Verify update
    data, code, *_ = run_cli_json("node", "-s", s, "get", pid, node3_id)
    node = json_data(data)
    ok(node and node["properties"]["verdict"] == "success", "verdict updated")
    ok(node and node["properties"]["evidence"] == "confirmed", "evidence updated")

    # --- Node search ---
    code, out, err = run_cli("node", "-s", s, "search", pid, "服务发现")
    ok(code == 0, "node search")
    ok("服务发现" in out, "search found '服务发现' in results")

    # --- Node delete: connected nodes are protected by the current CLI/API contract ---
    code, _, err = run_cli("node", "-s", s, "delete", pid, node3_id, expect_fail=True)
    ok(code != 0, "connected node delete rejected")
    ok("Cannot delete node" in err, "delete rejection explains connected edges")

    # Verify unchanged
    data, code, *_ = run_cli_json("node", "-s", s, "list", pid, "--json")
    nodes = json_data(data)
    ok(isinstance(nodes, list) and len(nodes) == 3, "3 nodes remain after rejected delete")

    return pid, node1_id, node2_id


def test_edge_crud_and_label_matching(ctx, pid, node1_id, node2_id):
    test("edge CRUD + label fuzzy matching")
    s = ctx.server_arg

    # Add a few more nodes for label matching
    data, code, *_ = run_cli_json("node", "-s", s, "add", pid, "Nginx 服务", "-t", "service",
                                   "--after", node2_id, "--edge-label", "发现",
                                   "--content", "http://10.0.0.1:80 nginx 1.18")
    ok(code == 0, "add Nginx service node")
    node3 = json_data(data)
    node3_id = node3["id"] if node3 else ""

    data, code, *_ = run_cli_json("node", "-s", s, "add", pid, "MySQL 服务", "-t", "service",
                                   "--after", node2_id, "--edge-label", "发现",
                                   "--content", "http://10.0.0.1:3306 mysql 5.7")
    ok(code == 0, "add MySQL service node")
    node4 = json_data(data)
    node4_id = node4["id"] if node4 else ""

    # --- Edge by UUID ---
    data, code, *_ = run_cli_json("edge", "-s", s, "add", pid, node3_id, node4_id,
                                   "--label", "服务关联")
    ok(code == 0, "edge add by UUID")
    ok(data and data.get("success") is True, "edge created")
    edge1 = json_data(data)
    edge1_id = edge1["id"] if edge1 else ""

    # --- Edge by exact label ---
    data, code, *_ = run_cli_json("edge", "-s", s, "add", pid,
                                    "--label", "回连验证",
                                    "--from-label", "MySQL 服务",
                                    "--to-label", "入口节点")
    ok(code == 0, "edge add by exact label match")

    # --- Edge by fuzzy label (partial match) ---
    # Create a node with unique partial name
    data, code, *_ = run_cli_json("node", "-s", s, "add", pid, "Tomcat 管理后台", "-t", "service",
                                   "--after", node3_id, "--edge-label", "目录扫描",
                                   "--content", "http://10.0.0.1:8080/manager")
    ok(code == 0, "add Tomcat admin node")
    node5 = json_data(data)
    node5_id = node5["id"] if node5 else ""

    # --- Server-side label matching (API direct call) ---
    import urllib.parse
    body = json.dumps({
        "source_node_id": None,
        "source_label": "Tomcat",
        "target_node_id": None,
        "target_label": "Nginx",
        "label": "模糊匹配测试",
    }).encode()
    req = urlrequest.Request(
        f"http://127.0.0.1:{ctx.port}/api/v1/projects/{pid}/edges",
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        resp = urlrequest.urlopen(req)
        resp_data = json.loads(resp.read())
        ok(resp_data.get("success") is True, "server-side fuzzy label match: Tomcat → Nginx")
    except urlerror.HTTPError as e:
        body_text = e.read().decode()
        ok(False, f"server-side fuzzy label match failed: {e.code} {body_text[:200]}")

    # --- Ambiguous label match ---
    body = json.dumps({
        "source_node_id": None,
        "source_label": "服务",  # matches "Nginx 服务" and "MySQL 服务"
        "target_node_id": None,
        "target_label": "入口节点",
        "label": "歧义测试",
    }).encode()
    req = urlrequest.Request(
        f"http://127.0.0.1:{ctx.port}/api/v1/projects/{pid}/edges",
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        urlrequest.urlopen(req)
        ok(False, "ambiguous label should have been rejected")
    except urlerror.HTTPError as e:
        body_text = e.read().decode()
        ok(e.code == 400, "ambiguous label rejected with 400")
        ok("ambiguous" in body_text.lower() or "歧义" in body_text or "matches" in body_text,
           "error mentions ambiguous/matches")

    # --- Not found label ---
    body = json.dumps({
        "source_node_id": None,
        "source_label": "不存在的节点",
        "target_node_id": None,
        "target_label": "入口节点",
        "label": "不存在测试",
    }).encode()
    req = urlrequest.Request(
        f"http://127.0.0.1:{ctx.port}/api/v1/projects/{pid}/edges",
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        urlrequest.urlopen(req)
        ok(False, "non-existent label should have been rejected")
    except urlerror.HTTPError as e:
        ok(e.code == 400, "non-existent label rejected with 400")

    # --- Edge list ---
    data, code, *_ = run_cli_json("edge", "-s", s, "list", pid)
    ok(code == 0, "edge list")
    edges = json_data(data)
    ok(isinstance(edges, list), "edges returned as array")
    ok(isinstance(edges, list) and len(edges) >= 2, f"at least 2 edges (got {len(edges) if isinstance(edges, list) else 0})")

    # --- Edge update ---
    data, code, out, _ = run_cli_json("edge", "-s", s, "update", pid, edge1_id, "--label", "更新后的标签")
    ok(code == 0, "edge update")

    # --- Edge delete ---
    data, code, *_ = run_cli_json("edge", "-s", s, "delete", pid, edge1_id)
    ok(code == 0, "edge delete")


def test_credential_cli_crud_and_validation(ctx):
    test("credential CLI CRUD + validation rules")
    s = ctx.server_arg
    credential_project_id = create_project_cli(s, ctx.project_name("凭据CLI测试"))
    credential_source_node_id = create_node_cli(s, credential_project_id, "凭据来源节点", "-t", "service",
                                                "--content", "http://10.0.0.1/credential-source")
    if not credential_project_id or not credential_source_node_id:
        ok(False, "credential test setup failed")
        return

    # --- Add with source node prefix ---
    data, code, *_ = run_cli_json("credential", "-s", s, "add", credential_project_id, "DB Root Password",
                                   "-t", "password", "--secret", "MyS3cret!@#",
                                   "--username", "root", "--source-node", credential_source_node_id[:8],
                                   "--status", "unverified", "--notes", "from env", "--json")
    ok(code == 0, "credential add --json")
    ok(data and data.get("success") is True, "credential add response success")
    credential = json_data(data)
    credential_id = credential["id"] if credential else ""
    ok(credential and credential["label"] == "DB Root Password", "credential label stored")
    ok(credential and credential["username"] == "root", "credential username stored")
    ok(credential and credential["source_node_id"] == credential_source_node_id, "source node prefix resolved")

    # --- List table masks secrets by default ---
    code, out, _ = run_cli("credential", "-s", s, "list", credential_project_id)
    ok(code == 0, "credential list table")
    ok("DB Root Password" in out, "credential appears in table")
    ok("********" in out and "MyS3cret!@#" not in out, "secret masked by default")

    # --- List table can show secrets explicitly ---
    code, out, _ = run_cli("credential", "-s", s, "list", credential_project_id, "--show-secrets")
    ok(code == 0, "credential list --show-secrets")
    ok("MyS3cret!@#" in out, "secret shown when requested")

    # --- List JSON returns full credential data ---
    data, code, *_ = run_cli_json("credential", "-s", s, "list", credential_project_id, "--json")
    ok(code == 0, "credential list --json")
    ok(data and data.get("success") is True, "credential list json success")
    credentials = json_data(data) or []
    created = next((c for c in credentials if c["id"] == credential_id), None)
    ok(created is not None, "created credential present in json list")
    ok(created and created["secret"] == "MyS3cret!@#", "json list includes secret value")

    # --- Update by UUID prefix ---
    code, out, _ = run_cli("credential", "-s", s, "update", credential_project_id, credential_id[:8],
                           "--status", "verified", "--secret", "NewSecret999",
                           "--notes", "verified over SSH")
    ok(code == 0, "credential update by prefix")
    ok("updated" in out, "credential update output confirms update")

    data, code, *_ = run_cli_json("credential", "-s", s, "list", credential_project_id, "--json")
    credentials = json_data(data) or []
    updated = next((c for c in credentials if c["id"] == credential_id), None)
    ok(updated and updated["status"] == "verified", "credential status updated")
    ok(updated and updated["secret"] == "NewSecret999", "credential secret updated")
    ok(updated and updated["notes"] == "verified over SSH", "credential notes updated")

    # --- Validation: invalid credential type is rejected before sending API request ---
    code, _, err = run_cli("credential", "-s", s, "add", credential_project_id, "Bad Credential",
                           "-t", "cookie", "--secret", "bad", expect_fail=True)
    ok(code != 0, "invalid credential type rejected")
    ok("invalid credential type" in err, "invalid type error explains expected values")

    # --- Delete by UUID prefix ---
    code, out, _ = run_cli("credential", "-s", s, "delete", credential_project_id, credential_id[:8])
    ok(code == 0, "credential delete by prefix")
    ok("deleted" in out, "credential delete output confirms deletion")

    data, code, *_ = run_cli_json("credential", "-s", s, "list", credential_project_id, "--json")
    remaining = json_data(data) or []
    ok(all(c["id"] != credential_id for c in remaining), "deleted credential absent from list")


def test_analysis(ctx, pid):
    test("project analysis (dashboard + JSON)")
    s = ctx.server_arg

    # Dashboard format (default)
    code, out, _ = run_cli("project", "-s", s, "analysis", pid)
    ok(code == 0, "analysis dashboard output")
    ok("攻击路径" in out, "dashboard has '攻击路径' section")
    ok("未利用漏洞" in out or "漏洞" in out, "dashboard has vulnerability section")
    ok("死路" in out, "dashboard has '死路' section")
    ok("下一步" in out or "建议" in out, "dashboard has next steps")

    # JSON format
    data, code, *_ = run_cli_json("project", "-s", s, "analysis", pid, "--json")
    ok(code == 0, "analysis --json")
    ok(data and data.get("success") is True, "json response success")
    analysis = json_data(data) or {}
    ok("attack_paths" in analysis, "json has attack_paths")
    ok("unexploited_vulns" in analysis, "json has unexploited_vulns")
    ok("dead_ends" in analysis, "json has dead_ends")
    ok("next_steps" in analysis, "json has next_steps")

    # Verify next_steps have actual content (not generic "尝试利用此漏洞进一步深入")
    if analysis.get("next_steps"):
        for step in analysis["next_steps"]:
            ok("suggestion" in step, f"next_step has suggestion: {step.get('suggestion', '')[:60]}")
            # Should NOT be the old hardcoded message
            ok(step.get("suggestion") != "尝试利用此漏洞进一步深入",
               f"suggestion is contextual, not generic")


def test_import(ctx, pid):
    test("bulk import")
    s = ctx.server_arg

    # Create import JSON
    import_data = {
        "nodes": [
            {"label": "导入节点A", "node_type": "service", "content": "## Test\nhttp://10.0.0.1:9090/service"},
            {"label": "导入节点B", "node_type": "vulnerability", "content": "## Vuln\nhttp://10.0.0.1:9090/vuln"},
        ],
        "edges": [
            {"source_label": "导入节点A", "target_label": "导入节点B", "label": "发现漏洞"},
        ],
    }

    with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
        json.dump(import_data, f, ensure_ascii=False)
        tmp_path = f.name

    try:
        data, code, *_ = run_cli_json("import", pid, "--file", tmp_path, "-s", s)
        ok(code == 0, "import command succeeds")
        ok(data and data.get("success") is True, "import response success")
        result = json_data(data) or {}
        ok(result.get("nodes_created", 0) >= 2, f"nodes created: {result.get('nodes_created')}")
        ok(result.get("edges_created", 0) >= 1, f"edges created: {result.get('edges_created')}")
    finally:
        os.unlink(tmp_path)


def test_global_search(ctx, pid):
    test("global search")
    s = ctx.server_arg

    # Search via API (CLI doesn't have a global search command, but API exists)
    url = f"http://127.0.0.1:{ctx.port}/api/v1/search?q=Tomcat"
    try:
        resp = urlrequest.urlopen(url)
        data = json.loads(resp.read())
        ok(data.get("success") is True, "global search API")
        results = json_data(data)
        ok(isinstance(results, list), "search returns array")
    except Exception as e:
        ok(False, f"global search failed: {e}")


def test_content_stdin_and_file(ctx):
    test("content from stdin and file")
    s = ctx.server_arg

    # Create project
    data, code, *_ = run_cli_json("project", "-s", s, "create", ctx.project_name("内容测试"))
    ok(code == 0, "create content test project")
    project = json_data(data)
    pid = project["id"] if project else ""

    # First node (entry, no after)
    data, code, *_ = run_cli_json("node", "-s", s, "add", pid, "入口", "-t", "objective",
                                   "--content", "http://10.0.0.1/start")
    ok(code == 0, "entry node for content tests")
    node = json_data(data)
    nid = node["id"] if node else ""

    # Content from file
    with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
        f.write("# 扫描结果\n\nhttp://10.0.0.1:8080/scan-target 发现服务")
        tmp_path = f.name

    try:
        data, code, *_ = run_cli_json("node", "-s", s, "add", pid, "文件内容节点",
                                       "--after", nid, "--edge-label", "扫描",
                                       "--content-file", tmp_path)
        ok(code == 0, "node with --content-file")
        node = json_data(data)
        ok(node and "扫描结果" in (node.get("properties", {}).get("content") or ""),
           "file content loaded into node")
    finally:
        os.unlink(tmp_path)

    # Content from stdin
    proc = subprocess.run(
        [str(THESEUS_BIN), "node", "-s", s, "add", pid, "stdin内容节点",
         "--after", nid, "--edge-label", "测试", "--content-stdin", "--json"],
        input="从 stdin 输入 http://10.0.0.1:3000/stdin-test",
        capture_output=True, text=True, timeout=10,
    )
    ok(proc.returncode == 0, "node with --content-stdin")
    if proc.returncode == 0:
        data = json.loads(proc.stdout)
        node = json_data(data)
        ok(node and "stdin" in (node.get("properties", {}).get("content") or ""),
           "stdin content loaded into node")


def test_edge_cases(ctx):
    test("edge cases and error handling")
    s = ctx.server_arg

    # Delete non-existent project
    code, _, _ = run_cli("project", "-s", s, "delete", "00000000-0000-0000-0000-000000000000",
                         expect_fail=True)
    ok(code != 0, "delete non-existent project fails")

    # Get non-existent node
    code, _, _ = run_cli("node", "-s", s, "get", "00000000-0000-0000-0000-000000000000",
                         "00000000-0000-0000-0000-000000000001", expect_fail=True)
    ok(code != 0, "get non-existent node fails")

    # Invalid project ID
    code, _, err = run_cli("project", "-s", s, "delete", "not-a-uuid", expect_fail=True)
    ok(code != 0, "invalid UUID rejected")

    # Node type default
    data, code, *_ = run_cli_json("project", "-s", s, "create", ctx.project_name("类型测试"))
    project = json_data(data)
    pid = project["id"] if project else ""
    data, code, *_ = run_cli_json("node", "-s", s, "add", pid, "默认类型节点",
                                   "--content", "http://10.0.0.1/default")
    ok(code == 0, "node without -t defaults to 'note'")
    node = json_data(data)
    ok(node and node["node_type"] == "note", "default node_type is note")


# ── 主流程 ──

def main():
    if not THESEUS_BIN.exists():
        print(f"ERROR: theseus binary not found at {THESEUS_BIN}")
        print("Run: cargo build -p theseus")
        sys.exit(1)

    print("=" * 60)
    print("Theseus E2E 功能测试")
    print("=" * 60)

    ctx = TestContext()
    try:
        print("\n启动测试服务器...")
        ctx.start()
        print(f"服务器就绪: http://127.0.0.1:{ctx.port}")

        # Run all tests
        print("\n── Project CRUD ──")
        pid = test_project_crud(ctx)
        test_project_uuid_prefix(ctx, pid)

        print("\n── Node CRUD + 验证规则 ──")
        pid, node1_id, node2_id = test_node_crud_and_validation(ctx)

        print("\n── Edge CRUD + 标签模糊匹配 ──")
        test_edge_crud_and_label_matching(ctx, pid, node1_id, node2_id)

        print("\n── Credential CLI CRUD + 验证规则 ──")
        test_credential_cli_crud_and_validation(ctx)

        print("\n── Analysis ──")
        test_analysis(ctx, pid)

        print("\n── Import ──")
        test_import(ctx, pid)

        print("\n── Content stdin/file ──")
        test_content_stdin_and_file(ctx)

        print("\n── Global Search ──")
        test_global_search(ctx, pid)

        print("\n── Edge Cases ──")
        test_edge_cases(ctx)

    except Exception as e:
        print(f"\n💥 UNEXPECTED ERROR: {e}")
        import traceback
        traceback.print_exc()
    finally:
        print("\n清理测试环境...")
        ctx.stop()

    # Summary
    total = passed + failed
    print("\n" + "=" * 60)
    print(f"结果: {passed}/{total} 通过, {failed} 失败")
    print("=" * 60)

    sys.exit(1 if failed > 0 else 0)


if __name__ == "__main__":
    main()
