#!/usr/bin/env python3
"""Validate a 1C patch review bundle directory and optional zip archive."""

from __future__ import annotations

import argparse
import hashlib
import json
import zipfile
from pathlib import Path
from typing import Any


REQUIRED_FILES = {"manifest.json", "preflight.json", "preflight.md", "diff.json", "README.md"}


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


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 sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def issue(severity: str, code: str, message: str, *, path: Path | str | None = None, detail: dict[str, Any] | None = None) -> dict[str, Any]:
    result: dict[str, Any] = {"severity": severity, "code": code, "message": message}
    if path is not None:
        result["path"] = str(path)
    if detail:
        result["detail"] = detail
    return result


def safe_relative(relative_path: str) -> Path:
    path = Path(relative_path.replace("\\", "/"))
    if path.is_absolute() or ".." in path.parts or not str(path):
        raise ValueError(relative_path)
    return path


def all_files(root: Path) -> set[str]:
    if not root.exists():
        return set()
    return {str(path.relative_to(root)).replace("\\", "/") for path in root.rglob("*") if path.is_file()}


def expected_working_hash(record: dict[str, Any]) -> str | None:
    sha = record.get("sha256") or {}
    if isinstance(sha, dict):
        return sha.get("working")
    return None


def check_bundle(bundle_dir: Path, zip_path: Path | None = None) -> dict[str, Any]:
    findings: list[dict[str, Any]] = []
    file_checks: list[dict[str, Any]] = []

    if not bundle_dir.exists() or not bundle_dir.is_dir():
        findings.append(issue("error", "missing_bundle_dir", "Bundle directory is missing.", path=bundle_dir))
        return build_result(bundle_dir, zip_path, findings, file_checks, None)

    present = all_files(bundle_dir)
    for required in sorted(REQUIRED_FILES):
        if required not in present:
            findings.append(issue("error", "missing_bundle_file", "Required bundle file is missing.", path=bundle_dir / required))

    manifest_path = bundle_dir / "manifest.json"
    if not manifest_path.exists():
        return build_result(bundle_dir, zip_path, findings, file_checks, None)
    manifest = load_json(manifest_path)
    if manifest.get("schema") != "onec_patch_bundle.v1":
        findings.append(issue("error", "invalid_bundle_schema", "Bundle manifest schema is not onec_patch_bundle.v1.", path=manifest_path, detail={"schema": manifest.get("schema")}))

    preflight = load_json(bundle_dir / "preflight.json") if (bundle_dir / "preflight.json").exists() else {}
    if preflight.get("status") != "ready_for_review":
        findings.append(issue("error", "invalid_preflight_status", "Bundle preflight must be ready_for_review.", path=bundle_dir / "preflight.json", detail={"status": preflight.get("status")}))
    if not preflight.get("passed"):
        findings.append(issue("error", "preflight_not_passed", "Bundle preflight is not passed.", path=bundle_dir / "preflight.json"))

    diff = load_json(bundle_dir / "diff.json") if (bundle_dir / "diff.json").exists() else {}
    modified = [
        str(item.get("relative_path") or "").replace("\\", "/")
        for item in diff.get("files") or []
        if item.get("status") == "modified"
    ]
    manifest_relatives = [str(item.get("relative_path") or "").replace("\\", "/") for item in manifest.get("files") or []]
    if sorted(modified) != sorted(manifest_relatives):
        findings.append(issue("error", "bundle_diff_manifest_mismatch", "Modified diff files do not match bundle manifest files.", detail={"diff_modified": modified, "manifest_files": manifest_relatives}))

    expected_bundle_files = set(REQUIRED_FILES)
    for record in manifest.get("files") or []:
        bundle_path_raw = str(record.get("bundle_path") or "")
        try:
            bundle_path = safe_relative(bundle_path_raw)
        except ValueError:
            findings.append(issue("error", "unsafe_bundle_path", "Unsafe bundle_path in manifest.", detail={"bundle_path": bundle_path_raw}))
            continue
        expected_bundle_files.add(str(bundle_path).replace("\\", "/"))
        path = bundle_dir / bundle_path
        check: dict[str, Any] = {
            "relative_path": record.get("relative_path"),
            "bundle_path": str(bundle_path).replace("\\", "/"),
            "exists": path.exists(),
            "expected_sha256": expected_working_hash(record),
        }
        if not path.exists():
            findings.append(issue("error", "missing_modified_file", "Modified bundle file is missing.", path=path))
        else:
            actual = sha256_file(path)
            check["sha256"] = actual
            expected = expected_working_hash(record)
            if expected and actual != expected:
                findings.append(issue("error", "modified_file_hash_mismatch", "Modified bundle file hash does not match working hash.", path=path, detail={"expected": expected, "actual": actual}))
        file_checks.append(check)

    extra_files = sorted(present - expected_bundle_files)
    for relative in extra_files:
        findings.append(issue("warning", "extra_bundle_file", "Unexpected file in bundle directory.", path=bundle_dir / relative))

    if zip_path is None:
        candidate = bundle_dir.with_suffix(".zip")
        zip_path = candidate if candidate.exists() else None
    if zip_path is None:
        findings.append(issue("warning", "missing_bundle_zip", "Bundle zip archive was not found."))
    elif not zip_path.exists():
        findings.append(issue("error", "missing_bundle_zip", "Bundle zip archive path does not exist.", path=zip_path))
    else:
        try:
            with zipfile.ZipFile(zip_path, "r") as archive:
                bad_member = archive.testzip()
                if bad_member:
                    findings.append(issue("error", "invalid_bundle_zip_member", "Zip archive contains a corrupt member.", path=zip_path, detail={"member": bad_member}))
                zip_files = {name.replace("\\", "/") for name in archive.namelist() if not name.endswith("/")}
                if zip_files != present:
                    findings.append(issue("error", "bundle_zip_mismatch", "Zip contents differ from bundle directory files.", path=zip_path, detail={"missing_in_zip": sorted(present - zip_files), "extra_in_zip": sorted(zip_files - present)}))
        except zipfile.BadZipFile as exc:
            findings.append(issue("error", "invalid_bundle_zip", f"Invalid zip archive: {exc}", path=zip_path))

    return build_result(bundle_dir, zip_path, findings, file_checks, manifest)


def build_result(bundle_dir: Path, zip_path: Path | None, findings: list[dict[str, Any]], file_checks: list[dict[str, Any]], manifest: dict[str, Any] | None) -> dict[str, Any]:
    errors = [row for row in findings if row.get("severity") == "error"]
    warnings = [row for row in findings if row.get("severity") == "warning"]
    return {
        "schema": "onec_patch_bundle_check.v1",
        "bundle_dir": str(bundle_dir),
        "zip_path": str(zip_path) if zip_path else None,
        "bundle_schema": (manifest or {}).get("schema"),
        "passed": not errors,
        "findings": findings,
        "file_checks": file_checks,
        "counts": {
            "files": len(file_checks),
            "errors": len(errors),
            "warnings": len(warnings),
        },
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Validate a 1C patch review bundle.")
    parser.add_argument("--bundle-dir", type=Path, required=True)
    parser.add_argument("--zip", type=Path)
    parser.add_argument("--output", type=Path)
    args = parser.parse_args()

    result = check_bundle(args.bundle_dir, args.zip)
    if args.output:
        write_json(args.output, result)
    print(json.dumps({"output": str(args.output) if args.output else None, "passed": result["passed"], "counts": result["counts"]}, ensure_ascii=False))
    return 0 if result["passed"] else 2


if __name__ == "__main__":
    raise SystemExit(main())
