"""Deterministic, signed WordPress App Pack artifacts."""

from __future__ import annotations

import base64
import hashlib
import json
import subprocess
import zipfile
from pathlib import Path
from typing import Any

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey


ALLOWED_SUFFIXES = {
    ".php", ".css", ".js", ".json", ".md", ".txt", ".png", ".svg",
    ".jpg", ".jpeg", ".gif", ".woff", ".woff2",
}
MAX_FILES = 500
MAX_UNCOMPRESSED_BYTES = 10 * 1024 * 1024
ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)


class PackageBuildError(ValueError):
    pass


def build_signed_pack(
    *, source: Path, output_dir: Path, private_key_path: Path,
) -> dict[str, Any]:
    manifest = _validate_source(source)
    output_dir.mkdir(parents=True, exist_ok=True)
    artifact_name = f"{manifest['package_id']}-{manifest['version']}.zip"
    artifact = output_dir / artifact_name
    _write_deterministic_zip(source, artifact)
    artifact_bytes = artifact.read_bytes()
    digest = hashlib.sha256(artifact_bytes).hexdigest()
    private_key = serialization.load_pem_private_key(
        private_key_path.read_bytes(), password=None,
    )
    if not isinstance(private_key, Ed25519PrivateKey):
        raise PackageBuildError("Signing key is not Ed25519")
    signature = base64.b64encode(private_key.sign(artifact_bytes)).decode("ascii")
    release = {
        "schema_version": "1.0",
        "package_id": manifest["package_id"],
        "app_id": manifest["app_id"],
        "version": manifest["version"],
        "artifact_name": artifact_name,
        "artifact_size": len(artifact_bytes),
        "sha256": digest,
        "signature_algorithm": "Ed25519",
        "signature": signature,
        "minimum_bridge_version": manifest["minimum_bridge_version"],
        "minimum_wordpress_version": manifest["minimum_wordpress_version"],
        "minimum_php_version": manifest["minimum_php_version"],
        "capabilities": manifest["capabilities"],
        "plugin_file": manifest["plugin_file"],
        "release_notes": manifest.get("release_notes", []),
    }
    release_path = output_dir / f"{artifact_name}.release.json"
    release_path.write_text(
        json.dumps(release, indent=2, sort_keys=True) + "\n", encoding="utf-8",
    )
    return {**release, "artifact_path": str(artifact), "release_path": str(release_path)}


def verify_signed_pack(
    *, artifact: Path, release_path: Path, public_key_path: Path,
) -> dict[str, Any]:
    release = json.loads(release_path.read_text(encoding="utf-8"))
    artifact_bytes = artifact.read_bytes()
    digest = hashlib.sha256(artifact_bytes).hexdigest()
    if artifact.name != release.get("artifact_name"):
        raise PackageBuildError("Artifact filename does not match release record")
    if len(artifact_bytes) != int(release.get("artifact_size", -1)):
        raise PackageBuildError("Artifact size does not match release record")
    if digest != release.get("sha256"):
        raise PackageBuildError("Artifact SHA-256 verification failed")
    public_key = serialization.load_pem_public_key(public_key_path.read_bytes())
    if not isinstance(public_key, Ed25519PublicKey):
        raise PackageBuildError("Verification key is not Ed25519")
    try:
        public_key.verify(base64.b64decode(release["signature"]), artifact_bytes)
    except (InvalidSignature, ValueError, KeyError) as exc:
        raise PackageBuildError("Artifact Ed25519 signature verification failed") from exc
    _validate_zip_entries(artifact)
    return {
        "status": "verified",
        "package_id": release["package_id"],
        "version": release["version"],
        "sha256": digest,
    }


def _validate_source(source: Path) -> dict[str, Any]:
    source = source.resolve()
    manifest_path = source / "wordpress-app-pack.json"
    if not source.is_dir() or not manifest_path.is_file():
        raise PackageBuildError("Package source or wordpress-app-pack.json is missing")
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    entrypoint = source / str(manifest.get("entrypoint") or "")
    if not entrypoint.is_file() or entrypoint.parent != source:
        raise PackageBuildError("Package entrypoint is missing or outside package root")
    files = _source_files(source)
    for path in files:
        if path.suffix.lower() not in ALLOWED_SUFFIXES:
            raise PackageBuildError(f"Disallowed package file type: {path.name}")
        if path.suffix.lower() == ".php":
            result = subprocess.run(
                ["php", "-l", str(path)], capture_output=True, text=True, check=False,
            )
            if result.returncode != 0:
                raise PackageBuildError(f"PHP lint failed: {path.name}")
    return manifest


def _source_files(source: Path) -> list[Path]:
    files = []
    total = 0
    for path in sorted(source.rglob("*")):
        if path.is_symlink():
            raise PackageBuildError(f"Symbolic links are not allowed: {path.name}")
        if path.is_file():
            files.append(path)
            total += path.stat().st_size
    if not files or len(files) > MAX_FILES:
        raise PackageBuildError("Package file count is outside allowed limits")
    if total > MAX_UNCOMPRESSED_BYTES:
        raise PackageBuildError("Package exceeds uncompressed size limit")
    return files


def _write_deterministic_zip(source: Path, artifact: Path) -> None:
    with zipfile.ZipFile(artifact, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
        for path in _source_files(source):
            relative = Path(source.name) / path.relative_to(source)
            info = zipfile.ZipInfo(relative.as_posix(), ZIP_TIMESTAMP)
            info.compress_type = zipfile.ZIP_DEFLATED
            info.create_system = 3
            info.external_attr = (0o100644 & 0xFFFF) << 16
            archive.writestr(info, path.read_bytes(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)


def _validate_zip_entries(artifact: Path) -> None:
    with zipfile.ZipFile(artifact) as archive:
        infos = archive.infolist()
        if not infos or len(infos) > MAX_FILES:
            raise PackageBuildError("Artifact file count is outside allowed limits")
        total = 0
        root = None
        for info in infos:
            path = Path(info.filename)
            if path.is_absolute() or ".." in path.parts or len(path.parts) < 2:
                raise PackageBuildError("Artifact contains an unsafe path")
            root = root or path.parts[0]
            if path.parts[0] != root:
                raise PackageBuildError("Artifact contains multiple root directories")
            total += info.file_size
            if total > MAX_UNCOMPRESSED_BYTES:
                raise PackageBuildError("Artifact exceeds uncompressed size limit")
