#!/usr/bin/env python3
"""產生 AWOOOI package / Docker 供應鏈 repo-only baseline。

本工具只掃描 repo 內的 manifest、lockfile、Dockerfile 與 docker-compose
檔案，不安裝套件、不連外、不跑 CVE scan、不讀 secret、不修改 workflow 或
runtime。輸出用於 IwoooS 供應鏈治理的低摩擦證據基線。
"""

from __future__ import annotations

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

try:
    import tomllib
except ModuleNotFoundError:  # pragma: no cover - Python 3.10 fallback
    tomllib = None  # type: ignore[assignment]


TAIPEI = timezone(timedelta(hours=8))

IGNORED_DIRS = {
    ".git",
    ".next",
    ".turbo",
    "__pycache__",
    "node_modules",
    "test-results",
}

PACKAGE_JSON_NAMES = {"package.json"}
PYPROJECT_NAMES = {"pyproject.toml"}
REQUIREMENTS_PATTERN = re.compile(r"requirements(?:[-_.a-zA-Z0-9]*)?\.txt$")
DOCKERFILE_PATTERN = re.compile(r"(?:^|/)Dockerfile(?:\.[A-Za-z0-9_.-]+)?$")
COMPOSE_PATTERN = re.compile(r"(?:^|/)(?:docker-compose|compose)(?:[A-Za-z0-9_.-]*)?\.ya?ml$")
FROM_PATTERN = re.compile(r"^\s*FROM\s+(?:--platform=\S+\s+)?(?P<image>\S+)", re.IGNORECASE)
FROM_ALIAS_PATTERN = re.compile(r"\s+AS\s+(?P<alias>[A-Za-z0-9_.-]+)\s*$", re.IGNORECASE)
COPY_FROM_PATTERN = re.compile(r"^\s*COPY\s+--from=(?P<image>\S+)", re.IGNORECASE)
IMAGE_PATTERN = re.compile(r"^\s*image\s*:\s*[\"']?(?P<image>[^\"'#\s]+)", re.IGNORECASE)

LOCKFILE_NAMES = {
    "pnpm-lock.yaml",
    "package-lock.json",
    "yarn.lock",
    "poetry.lock",
    "uv.lock",
    "Pipfile.lock",
}

EXECUTION_BOUNDARIES = {
    "package_install_authorized": False,
    "dependency_upgrade_authorized": False,
    "lockfile_rewrite_authorized": False,
    "npm_audit_authorized": False,
    "pip_audit_authorized": False,
    "cve_scan_authorized": False,
    "docker_build_authorized": False,
    "docker_pull_authorized": False,
    "docker_push_authorized": False,
    "image_tag_change_authorized": False,
    "image_digest_pin_change_authorized": False,
    "registry_login_authorized": False,
    "secret_value_collection_allowed": False,
    "workflow_modification_authorized": False,
    "production_deploy_authorized": False,
    "runtime_gate_count": 0,
    "action_button_count": 0,
    "not_authorization": True,
}


def should_skip(path: Path) -> bool:
    return any(part in IGNORED_DIRS for part in path.parts)


def git_commit(root: Path) -> str:
    try:
        return subprocess.check_output(
            ["git", "rev-parse", "--short=8", "HEAD"],
            cwd=root,
            text=True,
            stderr=subprocess.DEVNULL,
        ).strip()
    except (OSError, subprocess.CalledProcessError):
        return "unknown"


def read_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


def package_manager_from_root(root: Path) -> str:
    package_json = root / "package.json"
    if not package_json.exists():
        return "unknown"
    data = read_json(package_json)
    value = data.get("packageManager")
    if isinstance(value, str):
        return value
    if (root / "pnpm-lock.yaml").exists():
        return "pnpm-lock-present"
    return "unknown"


def scan_package_json(root: Path, path: Path) -> dict[str, Any]:
    data = read_json(path)
    rel = path.relative_to(root).as_posix()
    dependency_keys = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]
    dependency_count = sum(len(data.get(key, {})) for key in dependency_keys if isinstance(data.get(key), dict))
    return {
        "path": rel,
        "name": data.get("name", "(unnamed)"),
        "private": data.get("private", False),
        "package_manager": data.get("packageManager"),
        "dependency_count": dependency_count,
        "has_scripts": isinstance(data.get("scripts"), dict) and bool(data.get("scripts")),
    }


def scan_pyproject(root: Path, path: Path) -> dict[str, Any]:
    text = path.read_text(encoding="utf-8")
    if tomllib is None:
        name_match = re.search(r"(?m)^\s*name\s*=\s*[\"'](?P<name>[^\"']+)", text)
        return {
            "path": path.relative_to(root).as_posix(),
            "name": name_match.group("name") if name_match else "(unnamed)",
            "dependency_count": len(re.findall(r"(?m)^\s*[\"'][^\"']+[\"']\s*,?\s*$", text)),
            "has_build_system": "[build-system]" in text,
        }

    data = tomllib.loads(text)
    project = data.get("project", {})
    poetry = data.get("tool", {}).get("poetry", {})
    name = project.get("name") or poetry.get("name") or "(unnamed)"
    dependencies = project.get("dependencies", [])
    optional = project.get("optional-dependencies", {})
    poetry_deps = poetry.get("dependencies", {})
    dependency_count = 0
    if isinstance(dependencies, list):
        dependency_count += len(dependencies)
    if isinstance(optional, dict):
        dependency_count += sum(len(value) for value in optional.values() if isinstance(value, list))
    if isinstance(poetry_deps, dict):
        dependency_count += len(poetry_deps)
    return {
        "path": path.relative_to(root).as_posix(),
        "name": name,
        "dependency_count": dependency_count,
        "has_build_system": "build-system" in data,
    }


def scan_requirements(root: Path, path: Path) -> dict[str, Any]:
    lines = path.read_text(encoding="utf-8").splitlines()
    entries = [
        line.strip()
        for line in lines
        if line.strip() and not line.lstrip().startswith("#") and not line.lstrip().startswith("-r ")
    ]
    pinned = [line for line in entries if "==" in line]
    return {
        "path": path.relative_to(root).as_posix(),
        "entry_count": len(entries),
        "pinned_entry_count": len(pinned),
        "unpinned_entry_count": len(entries) - len(pinned),
    }


def scan_dockerfile(root: Path, path: Path) -> dict[str, Any]:
    images: list[str] = []
    copy_from_images: list[str] = []
    stage_aliases: set[str] = set()
    for line in path.read_text(encoding="utf-8").splitlines():
        match = FROM_PATTERN.match(line)
        if match:
            image = match.group("image")
            if image not in stage_aliases:
                images.append(image)
            alias_match = FROM_ALIAS_PATTERN.search(line)
            if alias_match:
                stage_aliases.add(alias_match.group("alias"))
            continue
        copy_match = COPY_FROM_PATTERN.match(line)
        if copy_match:
            image = copy_match.group("image")
            if image not in stage_aliases:
                copy_from_images.append(image)
    return {
        "path": path.relative_to(root).as_posix(),
        "from_images": images,
        "from_image_count": len(images),
        "digest_pinned_from_image_count": sum(1 for image in images if "@" in image),
        "copy_from_images": copy_from_images,
        "copy_from_image_count": len(copy_from_images),
        "digest_pinned_copy_from_image_count": sum(1 for image in copy_from_images if "@" in image),
    }


def scan_compose(root: Path, path: Path) -> dict[str, Any]:
    images: list[str] = []
    for line in path.read_text(encoding="utf-8").splitlines():
        match = IMAGE_PATTERN.match(line)
        if match:
            images.append(match.group("image"))
    return {
        "path": path.relative_to(root).as_posix(),
        "image_refs": images,
        "image_ref_count": len(images),
        "digest_pinned_image_ref_count": sum(1 for image in images if "@" in image),
    }


def iter_repo_files(root: Path) -> list[Path]:
    files: list[Path] = []
    for path in root.rglob("*"):
        if path.is_file() and not should_skip(path.relative_to(root)):
            files.append(path)
    return sorted(files)


def build_snapshot(root: Path, generated_at: str | None = None) -> dict[str, Any]:
    generated_at = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
    files = iter_repo_files(root)

    package_json = [scan_package_json(root, path) for path in files if path.name in PACKAGE_JSON_NAMES]
    pyprojects = [scan_pyproject(root, path) for path in files if path.name in PYPROJECT_NAMES]
    requirements = [scan_requirements(root, path) for path in files if REQUIREMENTS_PATTERN.fullmatch(path.name)]
    dockerfiles = [
        scan_dockerfile(root, path)
        for path in files
        if DOCKERFILE_PATTERN.search(path.relative_to(root).as_posix())
    ]
    compose_files = [
        scan_compose(root, path)
        for path in files
        if COMPOSE_PATTERN.search(path.relative_to(root).as_posix())
    ]
    lockfiles = [
        path.relative_to(root).as_posix()
        for path in files
        if path.name in LOCKFILE_NAMES
    ]

    docker_base_image_count = sum(item["from_image_count"] for item in dockerfiles)
    docker_base_digest_count = sum(item["digest_pinned_from_image_count"] for item in dockerfiles)
    docker_copy_from_image_count = sum(item["copy_from_image_count"] for item in dockerfiles)
    docker_copy_from_digest_count = sum(item["digest_pinned_copy_from_image_count"] for item in dockerfiles)
    compose_image_count = sum(item["image_ref_count"] for item in compose_files)
    compose_digest_count = sum(item["digest_pinned_image_ref_count"] for item in compose_files)
    requirements_entry_count = sum(item["entry_count"] for item in requirements)
    requirements_unpinned_count = sum(item["unpinned_entry_count"] for item in requirements)

    gaps = []
    if "pnpm-lock.yaml" not in lockfiles:
        gaps.append("pnpm_lock_missing")
    if any(path.endswith(("package-lock.json", "yarn.lock")) for path in lockfiles):
        gaps.append("unexpected_node_lockfile_present")
    if pyprojects and not any(path.endswith(("poetry.lock", "uv.lock", "Pipfile.lock")) for path in lockfiles):
        gaps.append("python_lockfile_absent")
    if docker_base_image_count and docker_base_digest_count < docker_base_image_count:
        gaps.append("docker_base_images_not_all_digest_pinned")
    if docker_copy_from_image_count and docker_copy_from_digest_count < docker_copy_from_image_count:
        gaps.append("docker_copy_from_images_not_all_digest_pinned")
    if compose_image_count and compose_digest_count < compose_image_count:
        gaps.append("compose_images_not_all_digest_pinned")
    if requirements_unpinned_count:
        gaps.append("requirements_unpinned_entries_present")

    return {
        "schema_version": "package_supply_chain_baseline_v1",
        "status": "repo_only_inventory_ready_needs_owner_policy",
        "mode": "repo_snapshot_only_no_install_no_network_no_cve_scan",
        "generated_at": generated_at,
        "git_commit": git_commit(root),
        "package_manager": package_manager_from_root(root),
        "summary": {
            "package_json_count": len(package_json),
            "pyproject_count": len(pyprojects),
            "requirements_file_count": len(requirements),
            "requirements_entry_count": requirements_entry_count,
            "requirements_unpinned_entry_count": requirements_unpinned_count,
            "lockfile_count": len(lockfiles),
            "pnpm_lock_present": "pnpm-lock.yaml" in lockfiles,
            "npm_lock_present": any(path.endswith("package-lock.json") for path in lockfiles),
            "yarn_lock_present": any(path.endswith("yarn.lock") for path in lockfiles),
            "python_lockfile_count": sum(
                1 for path in lockfiles if path.endswith(("poetry.lock", "uv.lock", "Pipfile.lock"))
            ),
            "dockerfile_count": len(dockerfiles),
            "docker_base_image_count": docker_base_image_count,
            "docker_base_digest_pinned_count": docker_base_digest_count,
            "docker_copy_from_image_count": docker_copy_from_image_count,
            "docker_copy_from_digest_pinned_count": docker_copy_from_digest_count,
            "compose_file_count": len(compose_files),
            "compose_image_ref_count": compose_image_count,
            "compose_digest_pinned_image_ref_count": compose_digest_count,
            "gap_count": len(gaps),
            "owner_response_received_count": 0,
            "owner_response_accepted_count": 0,
            "runtime_gate_count": 0,
            "action_button_count": 0,
        },
        "package_json_manifests": package_json,
        "pyproject_manifests": pyprojects,
        "requirements_files": requirements,
        "lockfiles": lockfiles,
        "dockerfiles": dockerfiles,
        "compose_files": compose_files,
        "gaps": gaps,
        "next_owner_evidence_fields": [
            "package_manager_policy",
            "lockfile_owner",
            "python_lock_policy",
            "docker_base_image_policy",
            "compose_image_policy",
            "registry_owner",
            "cve_scan_window",
            "rollback_owner",
        ],
        "execution_boundaries": EXECUTION_BOUNDARIES,
        "operator_interpretation": [
            "此 baseline 只代表 repo 供應鏈來源盤點，不代表 CVE / license / SBOM 已驗收。",
            "Docker image 未全數 digest pinning 是 policy gap，不在本輪自動改 image tag。",
            "Python lockfile 缺口是 owner policy gap，不在本輪自動產生 lockfile。",
            "不得把此 snapshot 當成 install、upgrade、docker pull、registry login 或 deploy 授權。",
        ],
    }


def write_json(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--root",
        default=Path(__file__).resolve().parents[2],
        type=Path,
        help="Repository root. Defaults to the current script's repository.",
    )
    parser.add_argument("--generated-at", help="Override generated_at for committed snapshots.")
    parser.add_argument("--output", type=Path, help="Write snapshot JSON to this path.")
    args = parser.parse_args()

    root = args.root.resolve()
    snapshot = build_snapshot(root, generated_at=args.generated_at)
    if args.output:
        output = args.output
        if not output.is_absolute():
            output = root / output
        write_json(output, snapshot)

    summary = snapshot["summary"]
    print(
        "PACKAGE_SUPPLY_CHAIN_BASELINE_OK "
        f"package_json={summary['package_json_count']} "
        f"pyproject={summary['pyproject_count']} "
        f"requirements={summary['requirements_file_count']} "
        f"dockerfiles={summary['dockerfile_count']} "
        f"compose={summary['compose_file_count']} "
        f"gaps={summary['gap_count']} "
        f"runtime_gate={summary['runtime_gate_count']}"
    )


if __name__ == "__main__":
    main()
