#!/usr/bin/env python3
"""光湖服务器入口地图 Agent v2.

This component is intentionally read-only.  It verifies and presents the
node's canonical navigation map when an interactive shell starts, then writes
an acknowledgement receipt.  Authorization APIs remain responsible for
enforcing MapGate before registered actions.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import socket
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


DEFAULT_MAP_ROOT = Path("/etc/guanghu/navigation-maps")
DEFAULT_RECEIPT_ROOT = Path("/var/lib/guanghu/map-agent/receipts")


class MapAgentError(RuntimeError):
    """A map cannot be selected or verified."""


SAFE_UNIT = re.compile(r"^[A-Za-z0-9_.@-]+\.service$")
SAFE_PM2_NAME = re.compile(r"^[A-Za-z0-9_.@-]+$")


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for block in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def select_map(explicit_path: str | None, map_root: Path) -> Path:
    if explicit_path:
        path = Path(explicit_path)
        if not path.is_file():
            raise MapAgentError(f"MAP_MISSING: {path}")
        return path

    node_id = os.environ.get("GUANGHU_NODE_ID", "").strip()
    if node_id:
        path = map_root / f"{node_id}.json"
        if not path.is_file():
            raise MapAgentError(f"MAP_MISSING: {path}")
        return path

    candidates = sorted(map_root.glob("*.json"))
    if len(candidates) == 1:
        return candidates[0]
    if not candidates:
        raise MapAgentError(f"MAP_MISSING: no JSON map under {map_root}")
    raise MapAgentError(
        "NODE_UNKNOWN: set GUANGHU_NODE_ID when more than one map is installed"
    )


def verify_map(path: Path) -> tuple[dict[str, Any], str]:
    companion = path.with_suffix(path.suffix + ".sha256")
    if not companion.is_file():
        raise MapAgentError(f"MAP_HASH_MISSING: {companion}")

    expected = companion.read_text(encoding="utf-8").split()[0].lower()
    if len(expected) != 64 or any(ch not in "0123456789abcdef" for ch in expected):
        raise MapAgentError(f"MAP_HASH_INVALID: {companion}")

    actual = sha256_file(path)
    if actual != expected:
        raise MapAgentError(
            f"MAP_HASH_MISMATCH: expected={expected[:16]} actual={actual[:16]}"
        )

    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise MapAgentError(f"MAP_INVALID: {exc}") from exc

    required = ("schema_version", "node_id", "node_role", "modules", "routes")
    missing = [key for key in required if key not in data]
    if missing:
        raise MapAgentError(f"MAP_INVALID: missing {','.join(missing)}")
    if not isinstance(data["modules"], list) or not isinstance(data["routes"], list):
        raise MapAgentError("MAP_INVALID: modules and routes must be arrays")
    return data, actual


def run_probe(probe: dict[str, Any], deep_audit: bool) -> dict[str, Any]:
    probe_type = probe.get("type")
    required = bool(probe.get("required", True))
    if bool(probe.get("deep_only", False)) and not deep_audit:
        return {
            "type": probe_type,
            "required": required,
            "status": "skipped",
            "detail": "deep audit only",
        }

    try:
        if probe_type in {"file_exists", "directory_exists"}:
            path = Path(str(probe.get("path", "")))
            if not path.is_absolute():
                raise ValueError("path must be absolute")
            ok = path.is_file() if probe_type == "file_exists" else path.is_dir()
            detail = str(path)
        elif probe_type == "file_sha256":
            path = Path(str(probe.get("path", "")))
            expected = str(probe.get("sha256", "")).lower()
            if not path.is_absolute() or len(expected) != 64:
                raise ValueError("invalid path or sha256")
            actual = sha256_file(path) if path.is_file() else ""
            ok = actual == expected
            detail = f"{path} sha256={actual or 'missing'}"
        elif probe_type == "systemd_active":
            unit = str(probe.get("unit", ""))
            if not SAFE_UNIT.fullmatch(unit):
                raise ValueError("invalid systemd unit")
            result = subprocess.run(
                ["systemctl", "is-active", "--quiet", unit],
                check=False,
                timeout=float(probe.get("timeout_seconds", 3)),
            )
            ok = result.returncode == 0
            detail = unit
        elif probe_type == "pm2_online":
            name = str(probe.get("name", ""))
            if not SAFE_PM2_NAME.fullmatch(name):
                raise ValueError("invalid pm2 name")
            result = subprocess.run(
                ["pm2", "jlist"],
                check=False,
                capture_output=True,
                text=True,
                timeout=float(probe.get("timeout_seconds", 4)),
            )
            processes = json.loads(result.stdout or "[]") if result.returncode == 0 else []
            ok = any(
                item.get("name") == name
                and item.get("pm2_env", {}).get("status") == "online"
                for item in processes
            )
            detail = name
        elif probe_type == "tcp_connect":
            host = str(probe.get("host", "127.0.0.1"))
            port = int(probe.get("port", 0))
            if host not in {"127.0.0.1", "::1", "localhost"}:
                raise ValueError("only loopback TCP probes are allowed")
            if not 1 <= port <= 65535:
                raise ValueError("invalid TCP port")
            with socket.create_connection(
                (host, port), timeout=float(probe.get("timeout_seconds", 1.5))
            ):
                pass
            ok = True
            detail = f"{host}:{port}"
        elif probe_type == "manifest_verify":
            manifest = Path(str(probe.get("path", "")))
            if not deep_audit:
                return {
                    "type": probe_type,
                    "required": required,
                    "status": "skipped",
                    "detail": "deep audit only",
                }
            if not manifest.is_absolute() or not manifest.is_file():
                raise ValueError("manifest is missing or path is not absolute")
            result = subprocess.run(
                ["sha256sum", "--check", "--strict", str(manifest)],
                cwd=str(manifest.parent),
                check=False,
                capture_output=True,
                text=True,
                timeout=float(probe.get("timeout_seconds", 180)),
            )
            ok = result.returncode == 0
            detail = str(manifest)
        else:
            raise ValueError(f"unsupported probe type {probe_type!r}")
    except (
        FileNotFoundError,
        json.JSONDecodeError,
        OSError,
        subprocess.TimeoutExpired,
        ValueError,
    ) as exc:
        ok = False
        detail = str(exc)

    return {
        "type": probe_type,
        "required": required,
        "status": "pass" if ok else "fail",
        "detail": detail,
    }


def audit_runtime(data: dict[str, Any], deep_audit: bool) -> dict[str, Any]:
    module_results = []
    route_results = []

    for module in data["modules"]:
        probes = [run_probe(item, deep_audit) for item in module.get("probes", [])]
        failed = [item for item in probes if item["required"] and item["status"] == "fail"]
        module_results.append(
            {
                "code": module.get("code", "UNREGISTERED"),
                "name": module.get("name", "unnamed"),
                "status": "verified" if not failed else "drift",
                "probes": probes,
            }
        )

    for route in data["routes"]:
        probes = [run_probe(item, deep_audit) for item in route.get("probes", [])]
        failed = [item for item in probes if item["required"] and item["status"] == "fail"]
        route_results.append(
            {
                "target": route.get("target", "unknown"),
                "purpose": route.get("purpose", "unspecified"),
                "status": "verified" if not failed else "drift",
                "probes": probes,
            }
        )

    drift = [
        item["code"] for item in module_results if item["status"] == "drift"
    ] + [
        f"route:{item['target']}"
        for item in route_results
        if item["status"] == "drift"
    ]
    return {
        "status": "MAP_VERIFIED" if not drift else "MAP_DRIFT",
        "modules": module_results,
        "routes": route_results,
        "drift": drift,
        "deep_audit": deep_audit,
    }


def render_map(data: dict[str, Any], digest: str, audit: dict[str, Any]) -> str:
    modules = data["modules"]
    routes = data["routes"]
    lines = [
        "",
        "╔════════════════════════════════════════════════════╗",
        f"║  光湖服务器入口地图 Agent v2 · {audit['status']:<18}║",
        "╚════════════════════════════════════════════════════╝",
        f"  节点: {data['node_id']}",
        f"  角色: {data['node_role']}",
        f"  地图哈希: {digest}",
        f"  地图生成: {data.get('generated_at', 'unknown')}",
        "",
        f"  运行模块 ({len(modules)}):",
    ]
    runtime_by_code = {item["code"]: item for item in audit["modules"]}
    for module in modules:
        runtime = runtime_by_code.get(module.get("code"), {})
        lines.append(
            "    - {code} · {name} · 期望:{expected} · 实时:{actual}".format(
                code=module.get("code", "UNREGISTERED"),
                name=module.get("name", "unnamed"),
                expected=module.get("expected_state", "present"),
                actual=runtime.get("status", "unknown"),
            )
        )
    lines.append("")
    lines.append(f"  已登记路由 ({len(routes)}):")
    runtime_by_target = {item["target"]: item for item in audit["routes"]}
    for route in routes:
        runtime = runtime_by_target.get(route.get("target"), {})
        lines.append(
            "    - {target} · {purpose} · 实时:{state}".format(
                target=route.get("target", "unknown"),
                purpose=route.get("purpose", "unspecified"),
                state=runtime.get("status", "unknown"),
            )
        )
    if audit["drift"]:
        lines.extend(["", f"  漂移编号: {', '.join(audit['drift'])}"])
    lines.extend(
        [
            "",
            "  边界: 本入口只恢复地图；受控动作仍须经过工单、授权与 MapGate。",
            "",
        ]
    )
    return "\n".join(lines)


def safe_receipt_root(requested: Path) -> Path:
    try:
        requested.mkdir(parents=True, exist_ok=True, mode=0o750)
        return requested
    except PermissionError:
        runtime = os.environ.get("XDG_RUNTIME_DIR")
        fallback = Path(runtime) / "guanghu-map-agent" if runtime else Path(
            tempfile.gettempdir()
        ) / f"guanghu-map-agent-{os.getuid()}"
        fallback.mkdir(parents=True, exist_ok=True, mode=0o700)
        if fallback.stat().st_uid != os.getuid():
            raise MapAgentError(f"RECEIPT_PATH_UNSAFE: {fallback}")
        return fallback


def write_receipt(
    data: dict[str, Any],
    digest: str,
    audit: dict[str, Any],
    receipt_root: Path,
    persona: str,
) -> Path:
    receipt_root = safe_receipt_root(receipt_root)
    now = datetime.now(timezone.utc)
    receipt = {
        "schema_version": "guanghu.map-entry-receipt/v2",
        "node_id": data["node_id"],
        "map_sha256": digest,
        "persona": persona,
        "user": os.environ.get("USER", "unknown"),
        "host": socket.gethostname(),
        "read_at": now.isoformat().replace("+00:00", "Z"),
        "status": audit["status"],
        "drift": audit["drift"],
        "deep_audit": audit["deep_audit"],
        "module_results": audit["modules"],
        "route_results": audit["routes"],
    }
    name = f"{now.strftime('%Y%m%dT%H%M%S%fZ')}-{os.getpid()}.json"
    target = receipt_root / name
    fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as handle:
        json.dump(receipt, handle, ensure_ascii=False, indent=2)
        handle.write("\n")
    return target


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Verify and present a Guanghu node map")
    parser.add_argument("--map", dest="map_path")
    parser.add_argument("--map-root", default=str(DEFAULT_MAP_ROOT))
    parser.add_argument("--receipt-root", default=str(DEFAULT_RECEIPT_ROOT))
    parser.add_argument("--persona", default=os.environ.get("GUANGHU_PERSONA", "unknown"))
    parser.add_argument("--json", action="store_true")
    parser.add_argument("--no-receipt", action="store_true")
    parser.add_argument("--static-only", action="store_true")
    parser.add_argument("--deep-audit", action="store_true")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    try:
        path = select_map(args.map_path, Path(args.map_root))
        data, digest = verify_map(path)
        audit = (
            {
                "status": "MAP_VERIFIED",
                "modules": [],
                "routes": [],
                "drift": [],
                "deep_audit": False,
            }
            if args.static_only
            else audit_runtime(data, args.deep_audit)
        )
        receipt = None
        if not args.no_receipt:
            receipt = write_receipt(
                data, digest, audit, Path(args.receipt_root), args.persona
            )
        if args.json:
            print(
                json.dumps(
                    {
                        "status": audit["status"],
                        "node_id": data["node_id"],
                        "map_sha256": digest,
                        "module_count": len(data["modules"]),
                        "route_count": len(data["routes"]),
                        "drift": audit["drift"],
                        "deep_audit": audit["deep_audit"],
                        "receipt": str(receipt) if receipt else None,
                    },
                    ensure_ascii=False,
                    indent=2,
                )
            )
        else:
            print(render_map(data, digest, audit))
        return 0 if audit["status"] == "MAP_VERIFIED" else 24
    except (MapAgentError, OSError) as exc:
        print(f"光湖地图入口拒绝继续: {exc}", file=sys.stderr)
        return 23


if __name__ == "__main__":
    raise SystemExit(main())
