#!/usr/bin/env python3
"""Read-only, local first-pass signals for the registered source portfolio."""

import csv
import argparse
import json
import re
import subprocess
from collections import Counter
from pathlib import Path

CONTROL = Path(__file__).resolve().parents[2]
ROOT = CONTROL.parent
OUT = Path(__file__).resolve().parent
SKIP_PARTS = {"vendor", "node_modules", "dist", "build", "target", "coverage", ".gradle", "Pods", "third_party"}
CODE_EXTS = {".php", ".py", ".js", ".jsx", ".ts", ".tsx", ".vue", ".java", ".kt", ".go", ".sh", ".ps1", ".yaml", ".yml", ".json", ".xml", ".gradle"}
RULES = {
    "tls_verification_disabled": re.compile(r"CURLOPT_SSL_VERIFYPEER\s*,\s*(?:false|0)\b|CURLOPT_SSL_VERIFYHOST\s*,\s*0\b|\bverify\s*=\s*False\b|rejectUnauthorized\s*:\s*false\b", re.I),
    "shell_execution": re.compile(r"\b(?:shell_exec|passthru|proc_open|popen)\s*\(|\bshell\s*=\s*True\b|Runtime\.getRuntime\(\)\.exec\s*\(", re.I),
    "dynamic_code_execution": re.compile(r"\b(?:eval|assert)\s*\(\s*(?:\$_|request\.|input\(|req\.|params\[)", re.I),
    "unsafe_deserialization": re.compile(r"\bunserialize\s*\(\s*\$_|\bpickle\.loads?\s*\(\s*(?:request|input|data)", re.I),
    "private_key_material": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----"),
    "possible_hardcoded_secret": re.compile(r"\b(?:password|passwd|secret|api[_-]?key|access[_-]?key|private[_-]?key)\b\s*(?:=|:|=>)\s*['\"][^'\"\s]{8,}['\"]", re.I),
}
SENSITIVE_NAMES = re.compile(r"(^|/)(?:\.env(?:\.[^/]*)?|id_rsa|id_ed25519|[^/]+\.(?:pem|p12|pfx|jks|keystore))$", re.I)


def git(repo, *args):
    result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, timeout=30)
    return result.stdout if result.returncode == 0 else None


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--fallback-root", type=Path)
    args = parser.parse_args()
    inventory = []
    hits = []
    with (CONTROL / "control/zhctproject-repositories.csv").open(newline="") as f:
        entries = list(csv.DictReader(f))
    for entry in entries:
        repo = ROOT / entry["local_path"]
        source = "project_local"
        if not repo.exists() and args.fallback_root and (args.fallback_root / entry["name"]).is_dir():
            repo = args.fallback_root / entry["name"]
            source = "audit_clone"
        item = {"repo": entry["name"], "path": str(repo), "source": source, "present": repo.is_dir(), "symlink": repo.is_symlink(), "expected_remote": entry["remote_url"], "action": entry["default_action"]}
        if not item["present"] or item["symlink"] or git(repo, "rev-parse", "--is-inside-work-tree") != b"true\n":
            item["status"] = "unavailable"
            inventory.append(item)
            continue
        item.update(status="scanned", branch=(git(repo, "branch", "--show-current") or b"").decode().strip(), head=(git(repo, "rev-parse", "HEAD") or b"").decode().strip(), remote=(git(repo, "remote", "get-url", "origin") or b"").decode().strip(), dirty_paths=len((git(repo, "status", "--porcelain", "-z") or b"").split(b"\0")) - 1)
        paths = [p.decode("utf-8", "surrogateescape") for p in (git(repo, "ls-files", "-z") or b"").split(b"\0") if p]
        item["tracked_files"] = len(paths)
        item["scanned_text_files"] = 0
        for rel in paths:
            path = repo / rel
            if not path.is_file() or path.is_symlink() or any(part in SKIP_PARTS for part in Path(rel).parts):
                continue
            if SENSITIVE_NAMES.search(rel):
                hits.append({"repo": entry["name"], "rule": "sensitive_filename", "path": rel, "line": 0})
            if path.suffix.lower() in {".pem", ".key", ".p12", ".pfx"} and path.stat().st_size <= 1_000_000:
                data = path.read_bytes()
                for number, line in enumerate(data.decode("utf-8", "replace").splitlines(), 1):
                    if RULES["private_key_material"].search(line):
                        hits.append({"repo": entry["name"], "rule": "private_key_material", "path": rel, "line": number})
            if path.suffix.lower() not in CODE_EXTS or path.stat().st_size > 1_000_000:
                continue
            data = path.read_bytes()
            if b"\0" in data:
                continue
            item["scanned_text_files"] += 1
            for number, line in enumerate(data.decode("utf-8", "replace").splitlines(), 1):
                for rule, pattern in RULES.items():
                    if pattern.search(line):
                        hits.append({"repo": entry["name"], "rule": rule, "path": rel, "line": number})
        inventory.append(item)
    with (OUT / "inventory.json").open("w") as f:
        json.dump(inventory, f, ensure_ascii=False, indent=2)
    with (OUT / "signals.csv").open("w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=["repo", "rule", "path", "line"])
        writer.writeheader()
        writer.writerows(hits)
    summary = {"registered": len(entries), "scanned": sum(i["status"] == "scanned" for i in inventory), "unavailable": [i["repo"] for i in inventory if i["status"] != "scanned"], "tracked_files": sum(i.get("tracked_files", 0) for i in inventory), "text_files": sum(i.get("scanned_text_files", 0) for i in inventory), "signals": dict(Counter(h["rule"] for h in hits)), "signal_repos": dict(Counter(h["repo"] for h in hits))}
    with (OUT / "summary.json").open("w") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
    print(json.dumps(summary, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
