#!/usr/bin/env python3
"""
檢查 IwoooS Wazuh 只讀 API 接線邊界。

本 guard 只掃描 repo 內 source，不連線 Wazuh、不讀主機、不收 secret、
不啟用 active response，也不做任何 production runtime 動作。
"""

from __future__ import annotations

import argparse
import json
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any


TAIPEI = timezone(timedelta(hours=8))
NEXT_ROUTE_PATH = Path("apps/web/src/app/api/iwooos/wazuh/route.ts")
BACKEND_ROUTE_PATH = Path("apps/api/src/api/v1/iwooos.py")
BACKEND_SERVICE_PATH = Path("apps/api/src/services/iwooos_wazuh_readonly_status.py")
BACKEND_LIVE_METADATA_GATE_SERVICE_PATH = Path("apps/api/src/services/iwooos_wazuh_live_metadata_gate.py")
PUBLIC_PAGE_PATH = Path("apps/web/src/app/[locale]/iwooos/page.tsx")
PUBLIC_COMPONENT_ROOT = Path("apps/web/src/components/iwooos")


@dataclass(frozen=True)
class ForbiddenPattern:
    pattern_id: str
    pattern: re.Pattern[str]
    scope: str


ROUTE_REQUIRED_TOKENS = [
    "IWOOOS_WAZUH_READONLY_ENABLED",
    "IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT",
    "WAZUH_API_BASE_URL",
    "WAZUH_API_USERNAME",
    "WAZUH_API_PASSWORD",
    "process.env",
    "requireHttpsBaseUrl",
    "metadata_only_no_active_response_no_raw_payload",
    "active_response_authorized: false",
    "host_write_authorized: false",
    "runtime_gate_count: 0",
    "secret_value_collection_allowed: false",
    "raw_wazuh_payload_storage_allowed: false",
    "agent_identity_public_display_allowed: false",
    "internal_ip_public_display_allowed: false",
    "not_authorization: true",
    "redactedAgent",
    "alias: `agent-",
    "wazuh_agent_registry_empty",
    "wazuh_agent_registry_below_expected",
    "agent_registry_empty_count",
    "agent_below_expected_minimum_count",
    "agent_visibility_no_false_green_count",
]

BACKEND_REQUIRED_TOKENS = [
    "/api/iwooos/wazuh",
    "/api/v1/iwooos/wazuh",
    "load_iwooos_wazuh_readonly_status",
]

BACKEND_SERVICE_REQUIRED_TOKENS = [
    "IWOOOS_WAZUH_READONLY_ENABLED",
    "IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT",
    "WAZUH_API_BASE_URL",
    "WAZUH_API_USERNAME",
    "WAZUH_API_PASSWORD",
    "metadata_only_no_active_response_no_raw_payload",
    "active_response_authorized_count",
    "host_write_authorized_count",
    "runtime_gate_count",
    "raw_wazuh_payload_storage_allowed",
    "internal_ip_public_display_allowed",
    "redacted_agent",
    "wazuh_agent_registry_empty",
    "wazuh_agent_registry_below_expected",
    "agent_registry_empty_count",
    "agent_below_expected_minimum_count",
    "agent_visibility_no_false_green_count",
]

BACKEND_LIVE_METADATA_GATE_REQUIRED_TOKENS = [
    "iwooos_wazuh_live_metadata_gate_readback_v1",
    "committed_snapshot_readback_with_public_safe_wazuh_route_metadata",
    "boundary_markers",
    "secret_value_collection_allowed",
    "raw_wazuh_payload_storage_allowed",
    "wazuh_api_live_query_authorized",
    "wazuh_active_response_authorized",
    "host_write_authorized",
    "runtime_gate_count",
    "not_authorization",
    "正式路由讀回",
    "機密明文收集=false",
    "原始 Wazuh 載荷保存=false",
]


FORBIDDEN_PATTERNS = [
    ForbiddenPattern(
        "hardcoded_wazuh_private_url",
        re.compile(r"https?://(?:10|127|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.[^\s'\"`]+", re.IGNORECASE),
        "route_and_public_ui",
    ),
    ForbiddenPattern(
        "wazuh_default_api_port_literal",
        re.compile(r"(?<![A-Za-z0-9_])55000(?![A-Za-z0-9_])"),
        "route_and_public_ui",
    ),
    ForbiddenPattern(
        "node_tls_reject_unauthorized_disabled",
        re.compile(r"NODE_TLS_REJECT_UNAUTHORIZED|rejectUnauthorized\s*:\s*false", re.IGNORECASE),
        "route_and_public_ui",
    ),
    ForbiddenPattern(
        "hardcoded_wazuh_password_assignment",
        re.compile(r"WAZUH_API_PASSWORD\s*=\s*['\"]|password\s*:\s*['\"]|passwd\s*:\s*['\"]", re.IGNORECASE),
        "route",
    ),
    ForbiddenPattern(
        "hardcoded_wazuh_username_assignment",
        re.compile(r"WAZUH_API_USERNAME\s*=\s*['\"]|username\s*:\s*['\"]|user\s*:\s*['\"]", re.IGNORECASE),
        "route",
    ),
    ForbiddenPattern(
        "known_secret_shape",
        re.compile(r"Wooo-[0-9]{6,}"),
        "route_and_public_ui",
    ),
    ForbiddenPattern(
        "fake_soc_dashboard_copy",
        re.compile(
            r"IWOOOS SOC Dashboard|Threat Blocked|Wazuh Agents Status|Protected Nodes|Recent Automated Responses",
            re.IGNORECASE,
        ),
        "public_ui",
    ),
    ForbiddenPattern(
        "fake_cve_or_alert_fixture",
        re.compile(r"CVE-2025-55182|recentAlerts\s*:\s*\[|vulnerabilities\s*:\s*\[", re.IGNORECASE),
        "public_ui",
    ),
    ForbiddenPattern(
        "legacy_wazuh_dashboard_component",
        re.compile(r"FifaWazuhMonitor"),
        "public_ui",
    ),
    ForbiddenPattern(
        "raw_wazuh_payload_public_copy",
        re.compile(r"rawWazuhPayload|raw_wazuh_payload\s*[:=]\s*[\[{]|raw Wazuh payload\s*[:=]", re.IGNORECASE),
        "public_ui",
    ),
]


def now_iso() -> str:
    return datetime.now(TAIPEI).replace(microsecond=0).isoformat()


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def collect_public_ui_files(root: Path) -> list[Path]:
    files: list[Path] = []
    page = root / PUBLIC_PAGE_PATH
    if page.exists():
        files.append(PUBLIC_PAGE_PATH)
    component_root = root / PUBLIC_COMPONENT_ROOT
    if component_root.exists():
        files.extend(
            path.relative_to(root)
            for path in sorted(component_root.rglob("*"))
            if path.is_file() and path.suffix in {".ts", ".tsx", ".js", ".jsx"}
        )
    return files


def source_lines(text: str) -> list[tuple[int, str]]:
    return list(enumerate(text.splitlines(), start=1))


def pattern_applies(pattern: ForbiddenPattern, source_kind: str) -> bool:
    if pattern.scope == "route_and_public_ui":
        return True
    if pattern.scope == "route":
        return source_kind == "route"
    if pattern.scope == "public_ui":
        return source_kind == "public_ui"
    return False


def collect_forbidden_matches(root: Path) -> list[dict[str, Any]]:
    targets: list[tuple[str, Path]] = [
        ("route", NEXT_ROUTE_PATH),
        ("route", BACKEND_ROUTE_PATH),
        ("route", BACKEND_SERVICE_PATH),
        ("route", BACKEND_LIVE_METADATA_GATE_SERVICE_PATH),
    ]
    targets.extend(("public_ui", path) for path in collect_public_ui_files(root))

    matches: list[dict[str, Any]] = []
    for source_kind, relative_path in targets:
        path = root / relative_path
        if not path.exists():
            continue
        for line_number, line in source_lines(read_text(path)):
            for forbidden in FORBIDDEN_PATTERNS:
                if pattern_applies(forbidden, source_kind) and forbidden.pattern.search(line):
                    matches.append(
                        {
                            "path": relative_path.as_posix(),
                            "line": line_number,
                            "pattern_id": forbidden.pattern_id,
                            "source_kind": source_kind,
                        }
                    )
    return matches


def collect_missing_required_tokens(route_text: str, required_tokens: list[str]) -> list[str]:
    return [token for token in required_tokens if token not in route_text]


def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]:
    next_route = root / NEXT_ROUTE_PATH
    backend_route = root / BACKEND_ROUTE_PATH
    backend_service = root / BACKEND_SERVICE_PATH
    backend_live_metadata_gate_service = root / BACKEND_LIVE_METADATA_GATE_SERVICE_PATH
    next_route_present = next_route.exists()
    backend_route_present = backend_route.exists()
    backend_service_present = backend_service.exists()
    backend_live_metadata_gate_service_present = backend_live_metadata_gate_service.exists()
    next_route_text = read_text(next_route) if next_route_present else ""
    backend_route_text = read_text(backend_route) if backend_route_present else ""
    backend_service_text = read_text(backend_service) if backend_service_present else ""
    backend_live_metadata_gate_service_text = (
        read_text(backend_live_metadata_gate_service) if backend_live_metadata_gate_service_present else ""
    )
    public_ui_files = collect_public_ui_files(root)
    missing_required_tokens = {
        NEXT_ROUTE_PATH.as_posix(): collect_missing_required_tokens(next_route_text, ROUTE_REQUIRED_TOKENS),
        BACKEND_ROUTE_PATH.as_posix(): collect_missing_required_tokens(backend_route_text, BACKEND_REQUIRED_TOKENS),
        BACKEND_SERVICE_PATH.as_posix(): collect_missing_required_tokens(
            backend_service_text, BACKEND_SERVICE_REQUIRED_TOKENS
        ),
        BACKEND_LIVE_METADATA_GATE_SERVICE_PATH.as_posix(): collect_missing_required_tokens(
            backend_live_metadata_gate_service_text,
            BACKEND_LIVE_METADATA_GATE_REQUIRED_TOKENS,
        ),
    }
    missing_required_token_count = sum(len(tokens) for tokens in missing_required_tokens.values())
    forbidden_matches = collect_forbidden_matches(root)

    return {
        "schema_version": "wazuh_readonly_route_boundary_guard_v1",
        "generated_at": generated_at or now_iso(),
        "status": (
            "pass"
            if (
                next_route_present
                and backend_route_present
                and backend_service_present
                and backend_live_metadata_gate_service_present
                and not missing_required_token_count
                and not forbidden_matches
            )
            else "blocked"
        ),
        "mode": "repo_source_scan_no_runtime_no_secret_collection",
        "guarded_route_paths": [
            NEXT_ROUTE_PATH.as_posix(),
            BACKEND_ROUTE_PATH.as_posix(),
            BACKEND_SERVICE_PATH.as_posix(),
            BACKEND_LIVE_METADATA_GATE_SERVICE_PATH.as_posix(),
        ],
        "guarded_public_ui_paths": [path.as_posix() for path in public_ui_files],
        "required_route_tokens": {
            NEXT_ROUTE_PATH.as_posix(): ROUTE_REQUIRED_TOKENS,
            BACKEND_ROUTE_PATH.as_posix(): BACKEND_REQUIRED_TOKENS,
            BACKEND_SERVICE_PATH.as_posix(): BACKEND_SERVICE_REQUIRED_TOKENS,
            BACKEND_LIVE_METADATA_GATE_SERVICE_PATH.as_posix(): BACKEND_LIVE_METADATA_GATE_REQUIRED_TOKENS,
        },
        "forbidden_pattern_ids": [pattern.pattern_id for pattern in FORBIDDEN_PATTERNS],
        "summary": {
            "route_present_count": (
                int(next_route_present)
                + int(backend_route_present)
                + int(backend_service_present)
                + int(backend_live_metadata_gate_service_present)
            ),
            "live_metadata_gate_service_present_count": 1 if backend_live_metadata_gate_service_present else 0,
            "next_route_present_count": 1 if next_route_present else 0,
            "backend_route_present_count": 1 if backend_route_present else 0,
            "backend_service_present_count": 1 if backend_service_present else 0,
            "public_ui_file_count": len(public_ui_files),
            "required_token_count": len(ROUTE_REQUIRED_TOKENS)
            + len(BACKEND_REQUIRED_TOKENS)
            + len(BACKEND_SERVICE_REQUIRED_TOKENS)
            + len(BACKEND_LIVE_METADATA_GATE_REQUIRED_TOKENS),
            "missing_required_token_count": missing_required_token_count,
            "forbidden_pattern_count": len(FORBIDDEN_PATTERNS),
            "forbidden_match_count": len(forbidden_matches),
            "readonly_api_default_closed_count": sum(
                "IWOOOS_WAZUH_READONLY_ENABLED" in text
                for text in [
                    next_route_text,
                    backend_route_text,
                    backend_service_text,
                    backend_live_metadata_gate_service_text,
                ]
            ),
            "server_side_env_required_count": sum(
                token in text
                for text in [
                    next_route_text,
                    backend_route_text,
                    backend_service_text,
                    backend_live_metadata_gate_service_text,
                ]
                for token in ["WAZUH_API_BASE_URL", "WAZUH_API_USERNAME", "WAZUH_API_PASSWORD"]
            ),
            "tls_disable_match_count": sum(
                1
                for item in forbidden_matches
                if item["pattern_id"] == "node_tls_reject_unauthorized_disabled"
            ),
            "hardcoded_private_url_match_count": sum(
                1 for item in forbidden_matches if item["pattern_id"] == "hardcoded_wazuh_private_url"
            ),
            "fake_soc_fixture_match_count": sum(
                1
                for item in forbidden_matches
                if item["pattern_id"] in {"fake_soc_dashboard_copy", "fake_cve_or_alert_fixture"}
            ),
            "active_response_authorized_count": 0,
            "host_write_authorized_count": 0,
            "runtime_gate_count": 0,
            "action_button_count": 0,
        },
        "execution_boundaries": {
            "runtime_execution_authorized": False,
            "wazuh_api_live_query_authorized": False,
            "wazuh_active_response_authorized": False,
            "host_read_authorized": False,
            "host_write_authorized": False,
            "secret_value_collection_allowed": False,
            "raw_wazuh_payload_storage_allowed": False,
            "agent_identity_public_display_allowed": False,
            "internal_ip_public_display_allowed": False,
            "frontend_public_raw_alert_display_allowed": False,
            "action_buttons_allowed": False,
            "not_authorization": True,
        },
        "missing_required_tokens": missing_required_tokens,
        "forbidden_matches": forbidden_matches,
        "operator_interpretation": [
            "Wazuh API code path 必須預設關閉，只有 server-side env 與 owner gate 允許只讀 metadata 查詢。",
            "不得硬編 Wazuh 內網 URL、使用者、密碼或關閉 TLS 驗證。",
            "前台不得顯示假 SOC dashboard、假 CVE、假 automated response、raw payload、agent 原名或內網 IP。",
            "此 guard 通過只代表 source 邊界合格，不代表 Wazuh live query、active response、host containment 或 runtime remediation 已授權。",
        ],
    }


def validate(root: Path) -> None:
    report = build_report(root)
    errors: list[str] = []
    if report["summary"]["next_route_present_count"] != 1:
        errors.append(f"{NEXT_ROUTE_PATH.as_posix()}: Wazuh Next.js 只讀 route 不存在")
    if report["summary"]["backend_route_present_count"] != 1:
        errors.append(f"{BACKEND_ROUTE_PATH.as_posix()}: Wazuh FastAPI 相容 route 不存在")
    if report["summary"]["backend_service_present_count"] != 1:
        errors.append(f"{BACKEND_SERVICE_PATH.as_posix()}: Wazuh FastAPI 只讀 service 不存在")
    if report["summary"]["live_metadata_gate_service_present_count"] != 1:
        errors.append(
            f"{BACKEND_LIVE_METADATA_GATE_SERVICE_PATH.as_posix()}: Wazuh 即時中繼資料閘門 service 不存在"
        )
    for path, tokens in report["missing_required_tokens"].items():
        for token in tokens:
            errors.append(f"{path}: 缺少必要只讀邊界 token {token!r}")
    for item in report["forbidden_matches"]:
        errors.append(
            f"{item['path']}:{item['line']}: 命中 Wazuh 邊界 forbidden pattern {item['pattern_id']}"
        )
    if errors:
        raise SystemExit("BLOCKED Wazuh readonly route boundary guard:\n" + "\n".join(f"- {error}" for error in errors))


def main() -> None:
    parser = argparse.ArgumentParser(description="檢查 IwoooS Wazuh 只讀 API 接線邊界")
    parser.add_argument("--root", default=".", help="repository root")
    parser.add_argument("--output", help="寫出 JSON 報告")
    parser.add_argument("--generated-at", help="固定報告時間，供 committed snapshot 使用")
    args = parser.parse_args()

    root = Path(args.root).resolve()
    report = build_report(root, args.generated_at)
    if args.output:
        output = Path(args.output)
        output.parent.mkdir(parents=True, exist_ok=True)
        output.write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    validate(root)
    summary = report["summary"]
    print(
        "WAZUH_READONLY_ROUTE_BOUNDARY_GUARD_OK "
        f"route={summary['route_present_count']} "
        f"public_ui_files={summary['public_ui_file_count']} "
        f"forbidden={summary['forbidden_match_count']} "
        f"runtime_gate={summary['runtime_gate_count']}"
    )


if __name__ == "__main__":
    main()
