"""Mechanism distribution and drift detection.

Gitea 1.25.5 cannot read a reusable workflow out of a private repository
(that is PR #32562, milestone 1.26.0), so the invariant scripts are copied into
consuming repositories. Copying is only safe if drift is detectable, so every
copy is pinned three ways: file hashes against a manifest, the manifest against
its own digest, and that digest against the policy record, which the consuming
repository cannot write.
"""

import hashlib
import json
import os
import shutil

PIN_FILENAME = ".publication-mechanism.json"


def canonical_json(obj):
    return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")


def sha256_file(path):
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        for chunk in iter(lambda: fh.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def sha256_bytes(data):
    return hashlib.sha256(data).hexdigest()


def compute_manifest(root, relpaths, version):
    files = {}
    for rel in sorted(relpaths):
        path = os.path.join(root, rel)
        if not os.path.isfile(path):
            raise FileNotFoundError(f"mechanism: {rel} not found under {root}")
        files[rel] = sha256_file(path)
    return {"mechanism_version": version, "files": files}


def manifest_digest(manifest):
    return sha256_bytes(canonical_json(manifest))


def write_manifest(path, manifest):
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(json.dumps(manifest, sort_keys=True, indent=2) + "\n")


def read_manifest(path):
    with open(path, "r", encoding="utf-8") as fh:
        return json.load(fh)


def install(source_root, target_root, manifest, pin_dir=None):
    """Copy the manifest's files into target_root and write the pin file."""
    for rel in sorted(manifest["files"]):
        src = os.path.join(source_root, rel)
        dst = os.path.join(target_root, rel)
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        shutil.copyfile(src, dst)
        os.chmod(dst, 0o755 if rel.endswith(".sh") or "/bin/" in rel else 0o644)
    pin = {
        "mechanism_version": manifest["mechanism_version"],
        "manifest_sha256": manifest_digest(manifest),
    }
    pin_path = os.path.join(pin_dir or target_root, PIN_FILENAME)
    with open(pin_path, "w", encoding="utf-8") as fh:
        fh.write(json.dumps(pin, sort_keys=True, indent=2) + "\n")
    return pin_path


def verify(target_root, manifest, policy_manifest_sha256, policy_mechanism_version):
    """Return failure reasons. Empty means the local copy is the pinned mechanism."""
    reasons = []
    digest = manifest_digest(manifest)

    if digest != policy_manifest_sha256:
        reasons.append(
            "mechanism: canonical manifest digest does not match the digest pinned in "
            "policy; the mechanism was changed without a policy change")
    if manifest["mechanism_version"] != policy_mechanism_version:
        reasons.append(
            f"mechanism: manifest version {manifest['mechanism_version']} does not match "
            f"policy version {policy_mechanism_version}")

    pin_path = os.path.join(target_root, PIN_FILENAME)
    if not os.path.isfile(pin_path):
        reasons.append(f"mechanism: {PIN_FILENAME} is absent from {target_root}")
    else:
        try:
            with open(pin_path, "r", encoding="utf-8") as fh:
                pin = json.load(fh)
        except json.JSONDecodeError as exc:
            reasons.append(f"mechanism: {PIN_FILENAME} is not valid JSON ({exc})")
            pin = None
        if pin is not None:
            if pin.get("manifest_sha256") != policy_manifest_sha256:
                reasons.append(
                    "mechanism: in-repo pin disagrees with policy; a repository cannot "
                    "bless its own mechanism version")
            if pin.get("mechanism_version") != policy_mechanism_version:
                reasons.append("mechanism: in-repo pin version disagrees with policy")

    for rel, expected in sorted(manifest["files"].items()):
        path = os.path.join(target_root, rel)
        if not os.path.isfile(path):
            reasons.append(f"mechanism: {rel} is missing from the consuming repository")
            continue
        actual = sha256_file(path)
        if actual != expected:
            reasons.append(f"mechanism: {rel} has drifted from the canonical copy")
    return reasons
