#!/usr/bin/env python3
"""檢查 Telegram 通知出口不可新增未登記 direct Bot API 旁路。

本 guard 只掃描 repo 原始碼與 committed snapshot，不讀 secret、不呼叫
Telegram、不修改 workflow / script / API sender。既有 direct send 仍是待
owner response 的基線；任何新增或變形的 direct Bot API endpoint 都必須先
進 inventory / owner request / migration plan，而不是直接合併。
"""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from collections import Counter
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any


TAIPEI = timezone(timedelta(hours=8))

SOURCE_SNAPSHOT = Path("docs/security/telegram-notification-egress-inventory.snapshot.json")
SCAN_ROOTS = (
    Path(".gitea/workflows"),
    Path("scripts/ops"),
    Path("scripts/ci"),
    Path("apps/api/src"),
)
SCAN_SUFFIXES = {".py", ".sh", ".js", ".yml", ".yaml"}
GUARDED_BOT_METHODS = (
    "sendMessage",
    "sendDocument",
    "sendPhoto",
    "sendMediaGroup",
    "editMessageText",
    "sendAnimation",
    "sendVideo",
    "sendAudio",
    "sendVoice",
)

BOT_ENDPOINT_RE = re.compile(
    r"api\.telegram\.org/bot.*?/(?P<method>"
    + "|".join(re.escape(method) for method in GUARDED_BOT_METHODS)
    + r")\b",
    re.IGNORECASE,
)
SECRET_INTERPOLATION_RE = re.compile(r"\$\{\{\s*secrets\.[^}]+\}\}")
BOT_TOKEN_URL_RE = re.compile(
    r"api\.telegram\.org/bot.*?/(?P<method>"
    + "|".join(re.escape(method) for method in GUARDED_BOT_METHODS)
    + r")\b",
    re.IGNORECASE,
)


def git_short_sha(root: Path) -> str:
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            cwd=root,
            check=True,
            capture_output=True,
            text=True,
        )
        return result.stdout.strip()
    except Exception:
        return "unknown"


def iter_scannable_files(root: Path) -> list[Path]:
    files: list[Path] = []
    for scan_root in SCAN_ROOTS:
        absolute_root = root / scan_root
        if not absolute_root.exists():
            continue
        for path in absolute_root.rglob("*"):
            if path.is_file() and path.suffix in SCAN_SUFFIXES:
                files.append(path)
    return sorted(files)


def sanitize_excerpt(line: str) -> str:
    excerpt = line.strip()
    excerpt = SECRET_INTERPOLATION_RE.sub("${{ secrets.<redacted> }}", excerpt)
    excerpt = BOT_TOKEN_URL_RE.sub(
        lambda match: f"api.telegram.org/bot<redacted>/{match.group('method')}",
        excerpt,
    )
    return excerpt[:180]


def signature(path: str, method: str, sanitized_excerpt: str) -> str:
    return f"{path}::{method.lower()}::{sanitized_excerpt}"


def load_source_snapshot(root: Path) -> dict[str, Any]:
    snapshot_path = root / SOURCE_SNAPSHOT
    return json.loads(snapshot_path.read_text(encoding="utf-8"))


def build_baseline(source_snapshot: dict[str, Any]) -> Counter[str]:
    baseline: Counter[str] = Counter()
    for item in source_snapshot.get("direct_bot_api_calls", []):
        excerpt = item.get("sanitized_excerpt", "")
        match = BOT_ENDPOINT_RE.search(excerpt)
        method = match.group("method") if match else "sendMessage"
        baseline[signature(item["path"], method, excerpt)] += 1
    return baseline


def scan_current_direct_endpoints(root: Path) -> list[dict[str, Any]]:
    findings: list[dict[str, Any]] = []
    for path in iter_scannable_files(root):
        relative_path = path.relative_to(root).as_posix()
        text = path.read_text(encoding="utf-8", errors="replace")
        for line_number, line in enumerate(text.splitlines(), start=1):
            for match in BOT_ENDPOINT_RE.finditer(line):
                method = match.group("method")
                sanitized = sanitize_excerpt(line)
                findings.append(
                    {
                        "path": relative_path,
                        "line": line_number,
                        "method": method,
                        "sanitized_excerpt": sanitized,
                        "signature": signature(relative_path, method, sanitized),
                    }
                )
    return findings


def method_counts(findings: list[dict[str, Any]]) -> dict[str, int]:
    counts = {method: 0 for method in GUARDED_BOT_METHODS}
    for item in findings:
        for method in GUARDED_BOT_METHODS:
            if item["method"].lower() == method.lower():
                counts[method] += 1
                break
    return counts


def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]:
    generated = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
    source_snapshot = load_source_snapshot(root)
    baseline = build_baseline(source_snapshot)
    current_findings = scan_current_direct_endpoints(root)
    remaining_baseline = baseline.copy()
    new_bypass_findings: list[dict[str, Any]] = []

    for item in current_findings:
        item_signature = item["signature"]
        if remaining_baseline[item_signature] > 0:
            remaining_baseline[item_signature] -= 1
            continue
        new_bypass_findings.append(item)

    removed_baseline_signatures = [
        {"signature": item_signature, "removed_count": count}
        for item_signature, count in sorted(remaining_baseline.items())
        if count > 0
    ]
    current_files = sorted({item["path"] for item in current_findings})
    new_bypass_files = sorted({item["path"] for item in new_bypass_findings})
    counts_by_method = method_counts(current_findings)
    source_summary = source_snapshot["summary"]

    return {
        "schema_version": "telegram_notification_egress_no_new_bypass_guard_v1",
        "generated_at": generated,
        "git_commit": git_short_sha(root),
        "status": "pass_no_new_bypass" if not new_bypass_findings else "blocked_new_bypass_detected",
        "mode": "repo_source_scan_no_secret_value_no_telegram_send",
        "source_snapshot": SOURCE_SNAPSHOT.as_posix(),
        "guarded_roots": [path.as_posix() for path in SCAN_ROOTS],
        "guarded_bot_methods": list(GUARDED_BOT_METHODS),
        "summary": {
            "source_direct_bot_api_call_count": source_summary["direct_bot_api_call_count"],
            "source_direct_bot_api_file_count": source_summary["direct_bot_api_file_count"],
            "baseline_signature_count": sum(baseline.values()),
            "current_direct_bot_api_call_count": len(current_findings),
            "current_direct_bot_api_file_count": len(current_files),
            "guarded_method_count": len(GUARDED_BOT_METHODS),
            "sendMessage_call_count": counts_by_method["sendMessage"],
            "sendDocument_call_count": counts_by_method["sendDocument"],
            "sendPhoto_call_count": counts_by_method["sendPhoto"],
            "sendMediaGroup_call_count": counts_by_method["sendMediaGroup"],
            "editMessageText_call_count": counts_by_method["editMessageText"],
            "other_guarded_method_call_count": sum(
                count
                for method, count in counts_by_method.items()
                if method
                not in {
                    "sendMessage",
                    "sendDocument",
                    "sendPhoto",
                    "sendMediaGroup",
                    "editMessageText",
                }
            ),
            "new_bypass_count": len(new_bypass_findings),
            "new_bypass_file_count": len(new_bypass_files),
            "removed_baseline_call_count": sum(item["removed_count"] for item in removed_baseline_signatures),
            "runtime_gate_count": 0,
            "action_button_count": 0,
        },
        "execution_boundaries": {
            "runtime_execution_authorized": False,
            "telegram_send_authorized": False,
            "bot_api_call_authorized": False,
            "workflow_modification_authorized": False,
            "script_modification_authorized": False,
            "api_sender_refactor_authorized": False,
            "secret_value_collection_allowed": False,
            "secret_hash_collection_allowed": False,
            "partial_token_collection_allowed": False,
            "chat_route_change_authorized": False,
            "bot_token_change_authorized": False,
            "raw_payload_storage_allowed": False,
            "production_write_authorized": False,
            "action_buttons_allowed": False,
            "not_authorization": True,
        },
        "current_direct_bot_api_calls": current_findings,
        "new_bypass_findings": new_bypass_findings,
        "removed_baseline_signatures": removed_baseline_signatures,
        "operator_interpretation": [
            "new_bypass_count 維持 0 才代表沒有新增未登記 Telegram Bot API 直送旁路。",
            "既有 18 個 sendMessage 旁路仍是待 owner response 的基線，不代表已批准或已收斂。",
            "sendDocument / sendPhoto / sendMediaGroup 等附件型出口若出現在 repo source，會被視為新增旁路並阻擋。",
            "本 guard 只讀 repo source 與 committed snapshot，不送 Telegram、不讀 Bot token、不修改 workflow / script / API sender。",
        ],
    }


def validate(root: Path) -> None:
    report = build_report(root)
    errors: list[str] = []
    if report["summary"]["new_bypass_count"]:
        for item in report["new_bypass_findings"]:
            errors.append(
                f"{item['path']}:{item['line']}: 新增未登記 Telegram Bot API 旁路 {item['method']}"
            )

    if errors:
        raise SystemExit(
            "BLOCKED telegram notification egress no-new-bypass guard:\n"
            + "\n".join(f"- {error}" for error in errors)
        )


def main() -> None:
    parser = argparse.ArgumentParser(description="檢查 Telegram 通知出口不可新增未登記 direct Bot API 旁路")
    parser.add_argument("--root", default=".", help="repository root")
    parser.add_argument("--output", help="寫出 JSON 報告")
    parser.add_argument("--generated-at", help="固定 generated_at 時間，供 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(
        "TELEGRAM_NOTIFICATION_EGRESS_NO_NEW_BYPASS_GUARD_OK "
        f"current={summary['current_direct_bot_api_call_count']} "
        f"baseline={summary['baseline_signature_count']} "
        f"new={summary['new_bypass_count']} "
        f"sendDocument={summary['sendDocument_call_count']} "
        f"runtime_gate={summary['runtime_gate_count']}"
    )


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