"""Repository export safety gate.

Classifies generator output and rejects control-plane, secret, and physical
Cloud topology material before any P2HS-authored git commit.
"""

from __future__ import annotations

import os
import re
from pathlib import Path

CLASS_A = "A"
CLASS_B = "B"
CLASS_C = "C"
CLASS_D = "D"

CONTROL_PATH_PREFIXES = (
    ".p2hs/",
    ".p2hs-control/",
    "p2hs/",
    ".cursor/",
)

# These are durable, non-secret governance artifacts.  The surrounding p2hs/
# tree remains platform-owned/local-only; only this explicit allowlist may cross
# the repository export boundary.
TRACKED_CANONICAL_METADATA = frozenset(
    {
        "p2hs/managed-runtime.json",
        "p2hs/dependency-build.json",
        "p2hs/platform-ports.lock.json",
    }
)

# Everything P2HS itself puts in a project repo (run payloads, imported-source snapshots,
# prompt media, the codegen runner and its workflow, agent-context files). None of it may
# ever be copied into a customer-owned repository, whatever its content or extension.
# This is a path rule on purpose: relying on the content leak-scan happens to exclude the
# runner script today, but only because it mentions a scanned host name.
# Paths are relative to the repo root, lower-case. tests/test_customer_publish_exclusions.py
# pins these against the constants in actions_pipeline.py so they cannot drift.
PLATFORM_OWNED_TOP_DIRS = frozenset(
    {".git", ".p2hs", ".p2hs-control", ".p2hs-user-uploads", "p2hs", ".cursor"}
)
PLATFORM_OWNED_FILES = frozenset(
    {
        "scripts/p2hs-codegen-action-runner.mjs",
        ".github/workflows/p2hs-codegen.yml",
        ".github/workflows/deploy.yml",
        "p2hs.platform.json",
        ".p2hs.json",
        ".cursorrules",
        "claude.md",
        "agents.md",
    }
)
PLATFORM_OWNED_WORKFLOW_PREFIX = ".github/workflows/p2hs"


def is_platform_owned_path(parts: list[str]) -> bool:
    """True for a repo-root-relative path (as path segments) that P2HS owns and must
    never publish to a customer repository."""
    if not parts:
        return False
    lowered = [part.lower() for part in parts]
    if lowered[0] in PLATFORM_OWNED_TOP_DIRS:
        return True
    relative = "/".join(lowered)
    return relative in PLATFORM_OWNED_FILES or relative.startswith(
        PLATFORM_OWNED_WORKFLOW_PREFIX
    )
CONTROL_PATHS = frozenset(
    {
        ".p2hs.json",
        ".cursorrules",
        "CLAUDE.md",
        "AGENTS.md",
        "p2hs.platform.json",
        "p2hs/project-brief.md",
        "p2hs/app-blocks-plan.json",
        "p2hs/generation-plan.json",
        "p2hs/discovery-cloud-contract.json",
        "p2hs/project-state.json",
        "p2hs/runtime-contract.json",
        "p2hs/runtime-dependencies.json",
        "p2hs/prompt-media",
    }
)
CONTROL_PATH_SUBSTRINGS = (
    ".p2hs/ai-context/",
    "p2hs/prompt-media/",
    ".p2hs/local-bak/",
    ".p2hs-local.bak",
)

SECRET_NAME_MARKERS = (
    "api_key",
    "apikey",
    "secret",
    "password",
    "private_key",
    "service_token",
    "cloud_service_token",
)

PHYSICAL_HOST_PATTERNS = (
    re.compile(r"cloudgate\.p2hs\.com", re.I),
    re.compile(r"stag-cloudgate\.p2hs\.com", re.I),
    re.compile(r"https?://[^/\s]*cloud\.p2hs\.com", re.I),
    re.compile(r"https?://[^/\s]*p2hs\.cloud", re.I),
)

PUBLIC_BFF_ENV_MARKERS = (
    "NUXT_PUBLIC_P2HS_GATEWAY_URL",
    "VITE_P2HS_GATEWAY_URL",
    "NUXT_PUBLIC_P2HS_CLOUD_PUBLIC_URL",
    "VITE_P2HS_CLOUD_PUBLIC_URL",
    "NUXT_PUBLIC_P2HS_CORE_PUBLIC_URL",
    "VITE_P2HS_CORE_PUBLIC_URL",
    "NEXT_PUBLIC_P2HS_BFF_URL",
    "REACT_APP_BFF_URL",
)

PROMPT_MARKERS = (
    "you are a codegen",
    "system prompt",
    "p2hs generation instructions",
    "architecture policy prompt",
    "## composition authority",
)

PUBLISHABLE_SUFFIXES = {
    ".vue",
    ".jsx",
    ".tsx",
    ".js",
    ".ts",
    ".mjs",
    ".cjs",
    ".css",
    ".scss",
    ".html",
    ".json",
    ".md",
    ".svg",
    ".png",
    ".jpg",
    ".jpeg",
    ".webp",
    ".woff",
    ".woff2",
    ".txt",
    ".yml",
    ".yaml",
}
PUBLISHABLE_NAMES = {
    "package.json",
    "package-lock.json",
    "pnpm-lock.yaml",
    "tsconfig.json",
    "nuxt.config.ts",
    "nuxt.config.js",
    "vite.config.ts",
    "vite.config.js",
    "wrangler.toml",
    "wrangler.jsonc",
    ".gitignore",
    "README.md",
    "index.html",
    "app.vue",
    "app.jsx",
    "app.tsx",
    "main.ts",
    "main.js",
    "error.vue",
}
PUBLISHABLE_PREFIXES = (
    "app/",
    "src/",
    "pages/",
    "components/",
    "layouts/",
    "public/",
    "server/",
    "functions/",
    "assets/",
    "styles/",
    "css/",
    "content/",
    "middleware/",
    "plugins/",
    "composables/",
    "lib/",
    "utils/",
    "types/",
    "tests/",
    "e2e/",
    "static/",
    "js/",
    ".github/",
)
BUNDLE_SCAN_DIRS = (
    "dist",
    "build",
    ".output",
    ".nuxt",
    ".next",
    "public/_nuxt",
)

_SKIP_DIRS = {
    ".git",
    "node_modules",
    ".nuxt",
    ".output",
    ".next",
    "dist",
    "build",
    ".venv",
    "venv",
    "__pycache__",
}


class RepoExportError(RuntimeError):
    """Commit rejected because the tree contains non-publishable material."""


def normalize_repo_relative_path(path: str | None) -> str:
    normalized = (path or "").replace("\\", "/").strip()
    if normalized.startswith("./"):
        normalized = normalized[2:]
    return normalized.lstrip("/")


def classify_path(path: str | None) -> str:
    relative = normalize_repo_relative_path(path)
    lowered = relative.lower()
    if lowered in TRACKED_CANONICAL_METADATA:
        return CLASS_A
    if not relative:
        return CLASS_C
    if relative in CONTROL_PATHS or any(
        relative.startswith(prefix) for prefix in CONTROL_PATH_PREFIXES
    ):
        return CLASS_C
    if any(marker in lowered for marker in CONTROL_PATH_SUBSTRINGS):
        return CLASS_C
    name = os.path.basename(lowered)
    if any(marker in name for marker in SECRET_NAME_MARKERS) and name.endswith(
        (".pem", ".key", ".env", ".json")
    ):
        return CLASS_D
    if name in {".env", ".env.local", ".env.production"}:
        return CLASS_D
    if name.endswith(".map") and any(relative.startswith(prefix) for prefix in BUNDLE_SCAN_DIRS):
        return CLASS_A
    suffixes = tuple(PUBLISHABLE_SUFFIXES)
    names = {item.lower() for item in PUBLISHABLE_NAMES}
    if relative.endswith(suffixes) or name in names:
        return CLASS_A
    if name in {"dockerfile", "makefile"} or relative.startswith(".github/"):
        return CLASS_A
    return CLASS_C


def is_allowlisted_path(path: str | None) -> bool:
    """True when the path may be staged into customer Git."""
    if is_forbidden_path(path):
        return False
    relative = normalize_repo_relative_path(path)
    if relative.lower() in TRACKED_CANONICAL_METADATA:
        return True
    name = os.path.basename(relative)
    if name in PUBLISHABLE_NAMES:
        return True
    if any(relative.startswith(prefix) for prefix in PUBLISHABLE_PREFIXES):
        return True
    if name in {"dockerfile", "Makefile", "makefile"} or relative.startswith(".github/"):
        return True
    if relative.endswith(tuple(PUBLISHABLE_SUFFIXES)):
        return True
    return False


def is_forbidden_path(path: str | None) -> bool:
    return classify_path(path) in {CLASS_C, CLASS_D}


def scan_text_for_leaks(relative: str, text: str) -> list[str]:
    hits: list[str] = []
    lowered = text.lower()
    for pattern in PHYSICAL_HOST_PATTERNS:
        if pattern.search(text):
            hits.append(f"{relative}: physical_cloud_hostname")
            break
    for marker in PUBLIC_BFF_ENV_MARKERS:
        if marker in text:
            hits.append(f"{relative}: public_bff_env")
            break
    if any(marker in lowered for marker in PROMPT_MARKERS) and classify_path(relative) != CLASS_A:
        hits.append(f"{relative}: generation_prompt")
    if "BEGIN PRIVATE KEY" in text or "CLOUD_SERVICE_TOKEN=" in text:
        hits.append(f"{relative}: secret_material")
    return hits


def scan_tree(repo_path: str | os.PathLike[str], *, staged_only: list[str] | None = None) -> list[str]:
    root = Path(repo_path)
    violations: list[str] = []
    if staged_only is not None:
        relatives = [normalize_repo_relative_path(item) for item in staged_only]
    else:
        relatives = []
        for dirpath, dirnames, filenames in os.walk(root):
            dirnames[:] = [name for name in dirnames if name not in _SKIP_DIRS]
            for name in filenames:
                full = Path(dirpath) / name
                relatives.append(normalize_repo_relative_path(str(full.relative_to(root))))
    for relative in relatives:
        if is_forbidden_path(relative):
            violations.append(f"{relative}: control_or_secret_path")
            continue
        path = root / relative
        if not path.is_file():
            continue
        try:
            text = path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue
        violations.extend(scan_text_for_leaks(relative, text))
    return violations


def scan_bundle_text(text: str, *, label: str = "bundle") -> list[str]:
    return scan_text_for_leaks(label, text)


def publishable_paths(repo_path: str | os.PathLike[str], changed: list[str]) -> list[str]:
    allowed: list[str] = []
    for relative in changed:
        normalized = normalize_repo_relative_path(relative)
        if not is_allowlisted_path(normalized):
            continue
        allowed.append(normalized)
    return allowed


def select_publishable_paths(changed: list[str]) -> list[str]:
    return publishable_paths(".", changed)


def scan_built_bundle(repo_path: str | os.PathLike[str]) -> list[str]:
    """Scan compiled frontend assets for physical BFF hosts and prompt leaks."""
    root = Path(repo_path)
    violations: list[str] = []
    for relative_dir in BUNDLE_SCAN_DIRS:
        folder = root / relative_dir
        if not folder.is_dir():
            continue
        for dirpath, dirnames, filenames in os.walk(folder):
            dirnames[:] = [name for name in dirnames if name not in _SKIP_DIRS]
            for name in filenames:
                if not name.endswith((".js", ".mjs", ".cjs", ".css", ".html", ".json", ".map")):
                    continue
                full = Path(dirpath) / name
                try:
                    text = full.read_text(encoding="utf-8")
                except (OSError, UnicodeDecodeError):
                    continue
                label = normalize_repo_relative_path(str(full.relative_to(root)))
                violations.extend(scan_text_for_leaks(label, text))
    return violations


def assert_bundle_clean(repo_path: str | os.PathLike[str]) -> None:
    violations = scan_built_bundle(repo_path)
    if violations:
        raise RepoExportError("client bundle leak: " + "; ".join(violations[:12]))


def assert_export_clean(repo_path: str | os.PathLike[str], staged: list[str] | None = None) -> None:
    violations = scan_tree(repo_path, staged_only=staged)
    if violations:
        raise RepoExportError("repo export rejected: " + "; ".join(violations[:12]))


def gitignore_control_plane_entries() -> str:
    return """\
# P2HS control plane — never commit
.p2hs.json
.p2hs/
.p2hs-control/
.cursor/
.cursorrules
CLAUDE.md
AGENTS.md
p2hs.platform.json
p2hs/
*.p2hs-local.bak
.p2hs/local-bak/
"""
