def registry_config_list(key, default=None):
    return config_list(REGISTRY_CONFIG, key, default or [])

def registry_config_str(key, default=""):
    return config_str(REGISTRY_CONFIG, key, default)

def registry_spec():
    value = REGISTRY_CONFIG.get("resolvedRegistry") if isinstance(REGISTRY_CONFIG, dict) else None
    return value if isinstance(value, dict) else {}

def registry_identity():
    spec = registry_spec()
    mode = config_str(spec, "mode", "host-path" if PROVIDER_ID.upper() == "G14" else "")
    namespace = config_str(spec, "namespace", "hwlab-ci" if PROVIDER_ID.upper() == "G14" else "")
    deployment = config_str(spec, "deploymentName", "hwlab-registry" if PROVIDER_ID.upper() == "G14" else "")
    pvc = config_str(spec, "pvcName", "")
    endpoint = config_str(spec, "endpoint", "127.0.0.1:5000")
    image = config_str(spec, "image", "registry:2.8.3")
    return {
        "mode": mode,
        "namespace": namespace,
        "deploymentName": deployment,
        "pvcName": pvc,
        "endpoint": endpoint,
        "image": image,
        "configRef": registry_config_str("configRef", "") if REGISTRY_CONFIG else ("legacy:G14" if PROVIDER_ID.upper() == "G14" else None),
    }

REGISTRY_ROOT = "/var/lib/hwlab/registry"
REGISTRY_REPOSITORY_ROOT = os.path.join(REGISTRY_ROOT, "docker/registry/v2/repositories")

def configure_registry_storage():
    global REGISTRY_ROOT, REGISTRY_REPOSITORY_ROOT
    identity = registry_identity()
    if REGISTRY_CONFIG and not config_bool(REGISTRY_CONFIG, "enabled", False):
        return {"ok": False, "diagnosis": registry_diagnosis("registry-retention-disabled", "Registry retention is disabled by YAML", configRef=identity.get("configRef"))}
    if identity.get("mode") == "host-path" and PROVIDER_ID.upper() == "G14":
        REGISTRY_ROOT = "/var/lib/hwlab/registry"
        REGISTRY_REPOSITORY_ROOT = os.path.join(REGISTRY_ROOT, "docker/registry/v2/repositories")
        return {"ok": True, "identity": identity, "storagePath": REGISTRY_ROOT, "storageKind": "host-path"}
    if identity.get("mode") != "k8s-workload":
        return {"ok": False, "diagnosis": registry_diagnosis("registry-target-mode-unsupported", "Registry retention requires a YAML-declared k8s-workload registry", mode=identity.get("mode"), configRef=identity.get("configRef"))}
    required = ["namespace", "deploymentName", "pvcName", "endpoint"]
    missing = [key for key in required if not identity.get(key)]
    if missing:
        return {"ok": False, "diagnosis": registry_diagnosis("registry-target-config-incomplete", "Registry configRef is missing required workload fields", missingFields=missing, configRef=identity.get("configRef"))}
    pvc = kubectl_json(["-n", identity["namespace"], "get", "pvc", identity["pvcName"]], 20)
    if not pvc:
        return {"ok": False, "diagnosis": registry_diagnosis("registry-pvc-unavailable", "Registry PVC could not be read", namespace=identity["namespace"], pvcName=identity["pvcName"], configRef=identity.get("configRef"))}
    volume_name = str(((pvc.get("spec") or {}).get("volumeName")) or "")
    if not volume_name or str(((pvc.get("status") or {}).get("phase")) or "") != "Bound":
        return {"ok": False, "diagnosis": registry_diagnosis("registry-pvc-not-bound", "Registry PVC must be Bound before retention planning", namespace=identity["namespace"], pvcName=identity["pvcName"], volumeName=volume_name or None)}
    pv = kubectl_json(["get", "pv", volume_name], 20)
    spec = (pv or {}).get("spec") or {}
    storage_path = str(((spec.get("local") or {}).get("path")) or ((spec.get("hostPath") or {}).get("path")) or "")
    if not storage_path.startswith("/"):
        return {"ok": False, "diagnosis": registry_diagnosis("registry-pv-storage-path-unsupported", "Registry PV does not expose a node-local absolute storage path", volumeName=volume_name, storageClass=spec.get("storageClassName"))}
    storage_path = os.path.realpath(storage_path)
    repository_root = os.path.join(storage_path, "docker/registry/v2/repositories")
    if not os.path.isdir(repository_root):
        return {"ok": False, "diagnosis": registry_diagnosis("registry-storage-layout-unavailable", "Registry repository layout is not readable from the node-local PVC path", storagePath=storage_path, repositoryRoot=repository_root)}
    REGISTRY_ROOT = storage_path
    REGISTRY_REPOSITORY_ROOT = repository_root
    return {"ok": True, "identity": identity, "storagePath": storage_path, "storageKind": "pvc", "volumeName": volume_name}

def registry_diagnosis(code, message, **details):
    return {"code": code, "message": message, "providerId": PROVIDER_ID, **details}

def active_registry_writes():
    automatic = REGISTRY_CONFIG.get("automatic") if isinstance(REGISTRY_CONFIG.get("automatic"), dict) else {}
    namespaces = config_list(automatic, "requireIdleNamespaces", [])
    writer_grace_minutes = config_int(automatic, "writerGraceMinutes", 30, minimum=1, maximum=1440)
    rows = []
    stale_rows = []
    commands = []
    for namespace in namespaces:
        result = kctl(["-n", namespace, "get", "pipelinerun,taskrun", "-o", "json"], 20)
        commands.append(bounded(result))
        if result["exitCode"] != 0:
            return {"ok": False, "activeCount": None, "activePreview": rows, "commands": commands, "namespace": namespace}
        pods_result = kctl(["-n", namespace, "get", "pod", "-o", "json"], 20)
        commands.append(bounded(pods_result))
        if pods_result["exitCode"] != 0:
            return {"ok": False, "activeCount": None, "activePreview": rows, "commands": commands, "namespace": namespace}
        try:
            items = json.loads(result.get("stdout") or "{}").get("items") or []
            pods = json.loads(pods_result.get("stdout") or "{}").get("items") or []
        except Exception:
            return {"ok": False, "activeCount": None, "activePreview": rows, "commands": commands, "namespace": namespace, "error": "invalid-kubernetes-json"}
        active_pods_by_run = {}
        for pod in pods:
            phase = str(((pod.get("status") or {}).get("phase")) or "")
            if phase not in set(["Pending", "Running", "Unknown"]):
                continue
            labels = (pod.get("metadata") or {}).get("labels") or {}
            for label in ["tekton.dev/pipelineRun", "tekton.dev/taskRun"]:
                run_name = str(labels.get(label) or "")
                if run_name:
                    active_pods_by_run.setdefault(run_name, []).append(str((pod.get("metadata") or {}).get("name") or "unknown"))
        for item in items:
            conditions = ((item.get("status") or {}).get("conditions")) or []
            terminal = any(condition.get("type") == "Succeeded" and condition.get("status") in set(["True", "False"]) for condition in conditions)
            if not terminal:
                meta = item.get("metadata") or {}
                name = str(meta.get("name") or "unknown")
                active_pods = active_pods_by_run.get(name) or []
                created = parse_iso_timestamp(meta.get("creationTimestamp"))
                age_seconds = max(0, time.time() - created) if created is not None else None
                within_grace = age_seconds is None or age_seconds < writer_grace_minutes * 60
                row = "%s/%s/%s" % (namespace, item.get("kind") or "Unknown", name)
                if active_pods or within_grace:
                    rows.append(row)
                else:
                    stale_rows.append(row)
    return {
        "ok": True,
        "activeCount": len(rows),
        "activePreview": rows[:40],
        "staleIgnoredCount": len(stale_rows),
        "staleIgnoredPreview": stale_rows[:40],
        "writerGraceMinutes": writer_grace_minutes,
        "commands": commands,
    }

def wait_no_active_registry_writes(timeout=180):
    deadline = time.time() + timeout
    last = None
    while time.time() < deadline:
        writes = active_registry_writes()
        last = {"writes": writes}
        if writes.get("ok") and int(writes.get("activeCount") or 0) == 0:
            return {"ok": True, "last": last}
        time.sleep(5)
    return {"ok": False, "last": last}

def kubectl_json(args, timeout=20):
    result = command(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl"] + args + ["-o", "json"], timeout)
    if result["exitCode"] != 0:
        return None
    try:
        return json.loads(result["stdout"] or "{}")
    except Exception:
        return None

def kctl(args, timeout=30):
    return command(["env", "KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl"] + args, timeout)

def workload_image_refs():
    script = """{
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get deploy,sts,ds -A -o jsonpath='{range .items[*]}{range .spec.template.spec.containers[*]}{.image}{\"\\n\"}{end}{range .spec.template.spec.initContainers[*]}{.image}{\"\\n\"}{end}{end}' 2>/dev/null
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pod -A --field-selector=status.phase=Running -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{\"\\n\"}{end}{range .spec.initContainers[*]}{.image}{\"\\n\"}{end}{end}' 2>/dev/null
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pod -A --field-selector=status.phase=Pending -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{\"\\n\"}{end}{range .spec.initContainers[*]}{.image}{\"\\n\"}{end}{end}' 2>/dev/null
} | sort -u"""
    result = command(["sh", "-lc", script], 30)
    refs = set()
    digests = set()
    for image in (result.get("stdout") or "").splitlines():
        image = image.strip()
        endpoint = registry_identity().get("endpoint") or ""
        prefix = endpoint.rstrip("/") + "/"
        if not image.startswith(prefix):
            continue
        ref = image[len(prefix):]
        if "@sha256:" in ref:
            repo, digest = ref.split("@", 1)
            refs.add((repo, "@" + digest))
            digests.add("sha256:" + digest.split(":", 1)[1])
        elif ":" in ref:
            repo, tag = ref.rsplit(":", 1)
            refs.add((repo, tag))
    return refs, digests, bounded(result)

def registry_request(method, path, headers=None, timeout=20):
    endpoint = registry_identity().get("endpoint") or ""
    url = (endpoint if endpoint.startswith("http://") or endpoint.startswith("https://") else "http://" + endpoint) + path
    req = urllib.request.Request(url, method=method, headers=headers or {})
    with urllib.request.urlopen(req, timeout=timeout) as response:
        body = response.read()
        return {"status": response.status, "headers": dict(response.headers), "body": body.decode("utf-8", errors="replace")}

def registry_tag_rows():
    rows = []
    root = REGISTRY_REPOSITORY_ROOT
    if not os.path.isdir(root):
        return rows
    for repo_root, dirs, files in os.walk(root):
        if os.path.basename(repo_root) != "tags":
            continue
        rel = os.path.relpath(repo_root, root)
        suffix = "/_manifests/tags"
        if not rel.endswith(suffix):
            continue
        repo = rel[:-len(suffix)]
        try:
            tags = os.listdir(repo_root)
        except OSError:
            continue
        for tag in sorted(tags):
            link = os.path.join(repo_root, tag, "current", "link")
            if not os.path.isfile(link):
                continue
            try:
                with open(link, "r", encoding="utf-8") as handle:
                    digest = handle.read().strip()
                stat = os.stat(link)
            except OSError:
                continue
            rows.append({
                "repo": repo,
                "tag": tag,
                "digest": digest,
                "mtime": stat.st_mtime,
                "mtimeIso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)),
                "path": os.path.join(repo_root, tag),
            })
    return rows

def registry_revision_rows():
    rows = []
    root = REGISTRY_REPOSITORY_ROOT
    if not os.path.isdir(root):
        return rows
    for repo_root, dirs, files in os.walk(root):
        if os.path.basename(repo_root) != "sha256":
            continue
        rel = os.path.relpath(repo_root, root)
        suffix = "/_manifests/revisions/sha256"
        if not rel.endswith(suffix):
            continue
        repo = rel[:-len(suffix)]
        try:
            revisions = os.listdir(repo_root)
        except OSError:
            continue
        for digest_hex in sorted(revisions):
            path = os.path.join(repo_root, digest_hex)
            link = os.path.join(path, "link")
            if not os.path.isfile(link):
                continue
            try:
                with open(link, "r", encoding="utf-8") as handle:
                    digest = handle.read().strip()
                stat = os.stat(link)
            except OSError:
                continue
            rows.append({
                "repo": repo,
                "digest": digest,
                "mtime": stat.st_mtime,
                "mtimeIso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)),
                "path": path,
            })
    return rows

def registry_retention_repo(repo):
    prefixes = registry_config_list("repositoryPrefixes", ["hwlab/hwlab-"])
    return any(repo.startswith(prefix) for prefix in prefixes)

def registry_protected_repo(repo):
    return any(repo.startswith(prefix) for prefix in registry_config_list("protectedRepositoryPrefixes", ["hwlab/cache/"]))

def registry_digest_hex(digest):
    if not isinstance(digest, str) or not digest.startswith("sha256:"):
        return None
    value = digest.split(":", 1)[1]
    if re.match(r"^[0-9a-f]{64}$", value) is None:
        return None
    return value

def registry_blob_data_path(digest):
    value = registry_digest_hex(digest)
    if value is None:
        return None
    return os.path.join(REGISTRY_ROOT, "docker/registry/v2/blobs/sha256", value[:2], value, "data")

_manifest_cache = {}
def registry_manifest_json(digest):
    if digest in _manifest_cache:
        return _manifest_cache[digest]
    path = registry_blob_data_path(digest)
    if path is None or not os.path.isfile(path):
        _manifest_cache[digest] = None
        return None
    try:
        with open(path, "rb") as handle:
            data = handle.read(8 * 1024 * 1024)
        value = json.loads(data.decode("utf-8"))
    except Exception:
        value = None
    _manifest_cache[digest] = value
    return value

def registry_manifest_refs(digest):
    manifest = registry_manifest_json(digest)
    if not isinstance(manifest, dict):
        return {"manifests": set(), "blobs": set()}
    manifests = set()
    blobs = set()
    config = manifest.get("config") or {}
    config_digest = config.get("digest")
    if isinstance(config_digest, str) and registry_digest_hex(config_digest) is not None:
        blobs.add(config_digest)
    for item in manifest.get("layers") or []:
        item_digest = (item or {}).get("digest")
        if isinstance(item_digest, str) and registry_digest_hex(item_digest) is not None:
            blobs.add(item_digest)
    for item in manifest.get("manifests") or []:
        item_digest = (item or {}).get("digest")
        if isinstance(item_digest, str) and registry_digest_hex(item_digest) is not None:
            manifests.add(item_digest)
            blobs.add(item_digest)
    return {"manifests": manifests, "blobs": blobs}

def registry_manifest_closure(seed):
    seen = set()
    stack = list(seed)
    while stack:
        digest = stack.pop()
        if digest in seen or registry_digest_hex(digest) is None:
            continue
        seen.add(digest)
        for child in registry_manifest_refs(digest).get("manifests") or []:
            if child not in seen:
                stack.append(child)
    return seen

def registry_blob_closure(seed):
    manifests = registry_manifest_closure(seed)
    blobs = set(manifests)
    for digest in manifests:
        blobs.update(registry_manifest_refs(digest).get("blobs") or [])
    return blobs

def registry_blob_size(digest):
    path = registry_blob_data_path(digest)
    if path is None or not os.path.isfile(path):
        return 0
    try:
        return int(os.lstat(path).st_blocks) * 512
    except OSError:
        return 0

def estimate_registry_reclaim(delete_manifest_digests, kept_manifest_digests):
    deleted = registry_blob_closure(delete_manifest_digests)
    kept = registry_blob_closure(kept_manifest_digests)
    reclaim = sorted(deleted - kept)
    limit = config_int(REGISTRY_CONFIG, "estimateBlobLimit", 5000, minimum=1, maximum=50000)
    sampled = reclaim[:limit]
    return {
        "bytes": sum(registry_blob_size(digest) for digest in sampled),
        "blobCount": len(reclaim),
        "sampledBlobCount": len(sampled),
        "truncated": len(sampled) < len(reclaim),
        "basis": "allocated-bytes-lower-bound" if len(sampled) < len(reclaim) else "allocated-bytes",
    }

def plan_registry_retention():
    storage = configure_registry_storage()
    if not storage.get("ok"):
        return {"tagRows": [], "revisionRows": [], "deleteRows": [], "deleteRevisionRows": [], "diagnosis": storage.get("diagnosis"), "summary": {"configRef": registry_identity().get("configRef"), "registrySizeBytes": 0, "estimatedReclaimBytes": 0}}
    keep_per_repo = config_int(REGISTRY_CONFIG, "keepPerRepo", int(OPTIONS.get("registryKeepPerRepo") or 20), minimum=1, maximum=200)
    min_age_hours = config_float(REGISTRY_CONFIG, "minAgeHours", float(OPTIONS.get("registryMinAgeHours") or 48), minimum=0.0)
    protected_tags = set(registry_config_list("protectedTags", ["latest"]))
    tag_pattern = registry_config_str("deletableTagPattern", r"^[0-9a-f]{7,40}$")
    try:
        deletable_tag = re.compile(tag_pattern)
    except re.error as exc:
        return {"tagRows": [], "revisionRows": [], "deleteRows": [], "deleteRevisionRows": [], "diagnosis": registry_diagnosis("registry-tag-pattern-invalid", "Registry deletableTagPattern is invalid", configRef=registry_identity().get("configRef"), error=str(exc)), "summary": {"configRef": registry_identity().get("configRef"), "registrySizeBytes": 0, "estimatedReclaimBytes": 0}}
    cutoff = time.time() - min_age_hours * 3600
    refs, digests, refs_command = workload_image_refs()
    rows = registry_tag_rows()
    revision_rows = registry_revision_rows()
    by_repo = {}
    for row in rows:
        by_repo.setdefault(row["repo"], []).append(row)
    keep = set()
    keep_reasons = {}
    for repo, items in by_repo.items():
        items.sort(key=lambda item: item["mtime"], reverse=True)
        for row in items[:keep_per_repo]:
            key = (row["repo"], row["tag"])
            keep.add(key)
            keep_reasons[key] = "latest-per-repo"
        for row in items:
            key = (row["repo"], row["tag"])
            if row["tag"] in protected_tags:
                keep.add(key)
                keep_reasons[key] = "protected-tag"
            if key in refs:
                keep.add(key)
                keep_reasons[key] = "workload-tag-ref"
            if row["digest"] in digests:
                keep.add(key)
                keep_reasons[key] = "workload-digest-ref"
            if registry_protected_repo(row["repo"]):
                keep.add(key)
                keep_reasons[key] = "cache-repo"
            if row["mtime"] >= cutoff:
                keep.add(key)
                keep_reasons[key] = "recent-tag"
    delete_rows = []
    kept_count = 0
    delete_by_repo = {}
    keep_by_repo = {}
    kept_digests = set()
    for row in rows:
        key = (row["repo"], row["tag"])
        should_delete = (
            key not in keep
            and registry_retention_repo(row["repo"])
            and not registry_protected_repo(row["repo"])
            and deletable_tag.match(row["tag"]) is not None
        )
        if should_delete:
            delete_rows.append(row)
            delete_by_repo[row["repo"]] = delete_by_repo.get(row["repo"], 0) + 1
        else:
            kept_count += 1
            kept_digests.add(row["digest"])
            keep_by_repo[row["repo"]] = keep_by_repo.get(row["repo"], 0) + 1
    shared_digest_rows = [row for row in delete_rows if row["digest"] in kept_digests]
    if shared_digest_rows:
        delete_rows = [row for row in delete_rows if row["digest"] not in kept_digests]
        kept_count += len(shared_digest_rows)
        delete_by_repo = {}
        for row in delete_rows:
            delete_by_repo[row["repo"]] = delete_by_repo.get(row["repo"], 0) + 1
        for row in shared_digest_rows:
            keep_by_repo[row["repo"]] = keep_by_repo.get(row["repo"], 0) + 1
    protected_digests = kept_digests | digests
    protected_digests.update(row["digest"] for row in revision_rows if not registry_retention_repo(row["repo"]))
    protected_digests = registry_manifest_closure(protected_digests)
    delete_revision_rows = []
    revision_delete_by_repo = {}
    for row in revision_rows:
        if not registry_retention_repo(row["repo"]):
            continue
        if row["digest"] in protected_digests:
            continue
        delete_revision_rows.append(row)
        revision_delete_by_repo[row["repo"]] = revision_delete_by_repo.get(row["repo"], 0) + 1
    kept_revision_digests = set(row["digest"] for row in revision_rows if row not in delete_revision_rows)
    delete_revision_digests = set(row["digest"] for row in delete_revision_rows)
    deletable_manifests = {}
    for row in delete_rows:
        if row["digest"] in kept_digests:
            continue
        deletable_manifests.setdefault(row["repo"], set()).add(row["digest"])
    for row in delete_revision_rows:
        deletable_manifests.setdefault(row["repo"], set()).add(row["digest"])
    deletable_manifest_count = sum(len(items) for items in deletable_manifests.values())
    registry_size = None
    estimate = estimate_registry_reclaim(delete_revision_digests, kept_revision_digests)
    return {
        "tagRows": rows,
        "revisionRows": revision_rows,
        "deleteRows": delete_rows,
        "deleteRevisionRows": delete_revision_rows,
        "summary": {
            "configRef": registry_identity().get("configRef"),
            "storageKind": storage.get("storageKind"),
            "storagePath": storage.get("storagePath"),
            "namespace": registry_identity().get("namespace"),
            "deploymentName": registry_identity().get("deploymentName"),
            "pvcName": registry_identity().get("pvcName"),
            "endpoint": registry_identity().get("endpoint"),
            "totalTags": len(rows),
            "totalRevisions": len(revision_rows),
            "repoCount": len(by_repo),
            "keepPerRepo": keep_per_repo,
            "minAgeHours": min_age_hours,
            "protectedWorkloadRefs": len(refs),
            "protectedDigestRefs": len(digests),
            "protectedDigestClosure": len(protected_digests),
            "keptTags": kept_count,
            "deleteTags": len(delete_rows),
            "sharedDigestProtectedTags": len(shared_digest_rows),
            "deleteManifests": deletable_manifest_count,
            "deleteRevisions": len(delete_revision_rows),
            "deleteByRepo": delete_by_repo,
            "revisionDeleteByRepo": revision_delete_by_repo,
            "keepByRepo": keep_by_repo,
            "registrySizeBytes": registry_size,
            "estimatedReclaimBytes": estimate.get("bytes"),
            "estimatedReclaimBlobCount": estimate.get("blobCount"),
            "estimatedReclaimSampledBlobCount": estimate.get("sampledBlobCount"),
            "estimatedReclaimTruncated": estimate.get("truncated"),
            "estimatedReclaimBasis": estimate.get("basis"),
        },
        "deleteManifestsByRepo": {repo: sorted(list(digests)) for repo, digests in deletable_manifests.items()},
        "refsCommand": refs_command,
    }

def registry_deployment_preflight():
    identity = registry_identity()
    namespace = identity.get("namespace")
    deployment_name = identity.get("deploymentName")
    dep = kubectl_json(["-n", namespace, "get", "deploy", deployment_name], 20)
    if not dep:
        return {"ok": False, "reason": "registry-deployment-missing", "diagnosis": registry_diagnosis("registry-deployment-missing", "Registry Deployment could not be read", namespace=namespace, deploymentName=deployment_name)}
    spec = ((dep.get("spec") or {}).get("template") or {}).get("spec") or {}
    containers = spec.get("containers") or []
    volumes = spec.get("volumes") or []
    registry_container = next((item for item in containers if item.get("name") == "registry"), containers[0] if containers else {})
    mounts = registry_container.get("volumeMounts") or []
    has_storage = any(
        vol.get("name") == "storage" and (
            ((vol.get("hostPath") or {}).get("path") == REGISTRY_ROOT)
            or ((vol.get("persistentVolumeClaim") or {}).get("claimName") == identity.get("pvcName"))
        )
        for vol in volumes
    )
    has_mount = any((mount.get("name") == "storage" and mount.get("mountPath") == "/var/lib/registry") for mount in mounts)
    image = str(registry_container.get("image") or "")
    expected_image = str(identity.get("image") or "")
    image_matches = image.startswith("registry:") if PROVIDER_ID.upper() == "G14" and not REGISTRY_CONFIG else image == expected_image
    env = {str(item.get("name") or ""): str(item.get("value") or "") for item in registry_container.get("env") or []}
    delete_enabled = env.get("REGISTRY_STORAGE_DELETE_ENABLED", "").lower() == "true"
    expected_delete_enabled = bool(registry_spec().get("deleteEnabled"))
    ok = bool(has_storage and has_mount and image_matches and spec.get("hostNetwork") is True and delete_enabled == expected_delete_enabled and delete_enabled)
    return {
        "ok": ok,
        "reason": "ok" if ok else "unexpected-registry-deployment-shape",
        "diagnosis": None if ok else registry_diagnosis("registry-deployment-shape-mismatch", "Registry Deployment does not match the YAML configRef", namespace=namespace, deploymentName=deployment_name, configRef=identity.get("configRef")),
        "namespace": namespace,
        "deploymentName": deployment_name,
        "pvcName": identity.get("pvcName"),
        "image": image,
        "expectedImage": expected_image,
        "hostNetwork": spec.get("hostNetwork"),
        "hasExpectedStorage": has_storage,
        "hasExpectedMount": has_mount,
        "deleteEnabled": delete_enabled,
        "expectedDeleteEnabled": expected_delete_enabled,
        "replicas": (dep.get("spec") or {}).get("replicas"),
        "readyReplicas": (dep.get("status") or {}).get("readyReplicas"),
    }

def cronjob_suspend_states(names):
    states = {}
    for name in names:
        data = kubectl_json(["-n", "hwlab-ci", "get", "cronjob", name], 15)
        if data:
            states[name] = bool(((data.get("spec") or {}).get("suspend")) is True)
    return states

def patch_cronjob_suspend(name, suspend):
    payload = json.dumps({"spec": {"suspend": bool(suspend)}})
    return kctl(["-n", "hwlab-ci", "patch", "cronjob", name, "--type=merge", "-p", payload], 30)

def wait_registry_pod_count(target, timeout=90):
    identity = registry_identity()
    namespace = identity.get("namespace")
    deployment_name = identity.get("deploymentName")
    deadline = time.time() + timeout
    last = None
    while time.time() < deadline:
        result = kctl(["-n", namespace, "get", "pods", "-l", "app.kubernetes.io/name=%s" % deployment_name, "--no-headers"], 20)
        last = bounded(result)
        lines = [line for line in (result.get("stdout") or "").splitlines() if line.strip()]
        active = []
        for line in lines:
            parts = line.split()
            status = parts[2] if len(parts) >= 3 else ""
            if status in set(["Completed", "Error", "Failed", "Succeeded"]):
                continue
            active.append(line)
        if len(active) == target:
            return {"ok": True, "lines": active, "allLines": lines, "last": last}
        time.sleep(2)
    return {"ok": False, "lines": [], "last": last}

def wait_pod_terminal(name, timeout=900):
    namespace = registry_identity().get("namespace")
    deadline = time.time() + timeout
    last = None
    while time.time() < deadline:
        data = kubectl_json(["-n", namespace, "get", "pod", name], 20)
        if data:
            phase = ((data.get("status") or {}).get("phase")) or ""
            last = {"phase": phase}
            if phase == "Succeeded":
                return {"ok": True, "phase": phase}
            if phase == "Failed":
                return {"ok": False, "phase": phase}
        time.sleep(3)
    return {"ok": False, "phase": "Timeout", "last": last}

def registry_gc_pod_overrides(identity):
    storage = {"name": "storage", "persistentVolumeClaim": {"claimName": identity.get("pvcName")}}
    return {
        "apiVersion": "v1",
        "spec": {
            "restartPolicy": "Never",
            "containers": [{
                "name": "registry-gc",
                "image": identity.get("image"),
                "command": ["registry", "garbage-collect", "/etc/docker/registry/config.yml"],
                "volumeMounts": [{"name": "storage", "mountPath": "/var/lib/registry"}],
            }],
            "volumes": [storage],
        },
    }

def execute_k8s_registry_maintenance(mode):
    storage = configure_registry_storage()
    if not storage.get("ok"):
        raise RuntimeError("registry storage preflight failed: %s" % ((storage.get("diagnosis") or {}).get("code") or "unknown"))
    identity = storage.get("identity") or registry_identity()
    deployment = registry_deployment_preflight()
    if not deployment.get("ok"):
        raise RuntimeError("registry deployment preflight failed: %s" % deployment.get("reason"))
    plan = plan_registry_retention()
    if plan.get("diagnosis"):
        raise RuntimeError("registry retention plan failed: %s" % ((plan.get("diagnosis") or {}).get("code") or "unknown"))
    delete_manifests = plan.get("deleteManifestsByRepo") or {}
    if mode == "retention" and not delete_manifests:
        return {"reclaimedBytes": 0, "commandOutput": {"message": "no unprotected registry manifests matched YAML retention", "registryPlan": plan.get("summary")}}
    idle = wait_no_active_registry_writes(180)
    if not idle.get("ok"):
        raise RuntimeError("refusing registry maintenance because configured writer namespaces are not idle")
    namespace = identity.get("namespace")
    deployment_name = identity.get("deploymentName")
    before = du_size(REGISTRY_ROOT, 60) or 0
    gc_name = "%s-registry-gc-%s" % (re.sub(r"[^a-z0-9]+", "-", PROVIDER_ID.lower()).strip("-") or "node", int(time.time()))
    steps = [{"step": "writer-idle-preflight", "result": idle}]
    deleted_manifests = []
    try:
        if mode == "retention":
            automatic = REGISTRY_CONFIG.get("automatic") if isinstance(REGISTRY_CONFIG.get("automatic"), dict) else {}
            max_delete = config_int(automatic, "maxDeleteManifestsPerRun", 100, minimum=1, maximum=1000)
            selected = []
            for repo in sorted(delete_manifests.keys()):
                for digest in sorted(delete_manifests.get(repo) or []):
                    selected.append((repo, digest))
            selected = selected[:max_delete]
            for repo, digest in selected:
                encoded_repo = "/".join(urllib.parse.quote(part, safe="") for part in repo.split("/"))
                try:
                    result = registry_request("DELETE", "/v2/%s/manifests/%s" % (encoded_repo, urllib.parse.quote(digest, safe=":")), {"Accept": "application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json"})
                    deleted_manifests.append({"repo": repo, "digest": digest, "status": result.get("status")})
                except urllib.error.HTTPError as exc:
                    if exc.code == 404:
                        deleted_manifests.append({"repo": repo, "digest": digest, "status": 404})
                    else:
                        raise
            steps.append({"step": "registry-api-delete-manifests", "count": len(deleted_manifests), "preview": deleted_manifests[:20]})
        scale_down = kctl(["-n", namespace, "scale", "deploy", deployment_name, "--replicas=0"], 60)
        steps.append({"step": "scale-registry-down", "result": bounded(scale_down)})
        if scale_down["exitCode"] != 0 or not wait_registry_pod_count(0, 120).get("ok"):
            raise RuntimeError("registry workload did not scale down")
        overrides = registry_gc_pod_overrides(identity)
        run_gc = kctl(["-n", namespace, "run", gc_name, "--restart=Never", "--image=%s" % identity.get("image"), "--overrides=%s" % json.dumps(overrides)], 60)
        steps.append({"step": "start-registry-gc-pod", "result": bounded(run_gc), "pod": gc_name})
        if run_gc["exitCode"] != 0:
            raise RuntimeError("failed to start registry GC pod")
        waited_gc = wait_pod_terminal(gc_name, 900)
        logs = kctl(["-n", namespace, "logs", gc_name], 120)
        steps.append({"step": "registry-gc-logs", "result": bounded(logs)})
        if not waited_gc.get("ok"):
            raise RuntimeError("registry GC pod did not complete successfully")
    finally:
        cleanup_gc = kctl(["-n", namespace, "delete", "pod", gc_name, "--ignore-not-found=true"], 60)
        steps.append({"step": "delete-registry-gc-pod", "result": bounded(cleanup_gc)})
        scale_up = kctl(["-n", namespace, "scale", "deploy", deployment_name, "--replicas=%s" % int(deployment.get("replicas") or 1)], 60)
        steps.append({"step": "scale-registry-up", "result": bounded(scale_up)})
        rollout = kctl(["-n", namespace, "rollout", "status", "deploy/%s" % deployment_name, "--timeout=180s"], 200)
        steps.append({"step": "wait-registry-rollout", "result": bounded(rollout)})
    readiness = registry_deployment_preflight()
    if not readiness.get("ok") or int(readiness.get("readyReplicas") or 0) < 1:
        raise RuntimeError("registry workload did not recover readiness")
    probe = registry_request("GET", "/v2/", timeout=20)
    if int(probe.get("status") or 0) != 200:
        raise RuntimeError("registry endpoint did not recover readiness")
    after = du_size(REGISTRY_ROOT, 60) or 0
    return {
        "reclaimedBytes": max(0, before - after),
        "commandOutput": {
            "registryPlan": plan.get("summary"),
            "deletedManifestCount": len(deleted_manifests),
            "diskBeforeBytes": before,
            "diskAfterBytes": after,
            "readiness": readiness,
            "steps": steps[-12:],
        },
    }

def execute_registry_retention():
    if registry_identity().get("mode") == "k8s-workload":
        return execute_k8s_registry_maintenance("retention")
    if PROVIDER_ID.upper() != "G14":
        raise RuntimeError("HWLAB registry retention is only supported on G14")
    deployment = registry_deployment_preflight()
    if not deployment.get("ok"):
        raise RuntimeError("registry deployment preflight failed: %s" % deployment.get("reason"))
    plan = plan_registry_retention()
    delete_rows = plan.get("deleteRows") or []
    delete_revision_rows = plan.get("deleteRevisionRows") or []
    delete_manifests = plan.get("deleteManifestsByRepo") or {}
    if not delete_rows and not delete_revision_rows:
        return {"reclaimedBytes": 0, "commandOutput": {"message": "no registry tags or revisions matched conservative retention", "registryPlan": plan.get("summary")}}
    if not delete_manifests:
        return {"reclaimedBytes": 0, "commandOutput": {"message": "matched manifests are still referenced by retained manifests; registry GC would not reclaim blobs", "registryPlan": plan.get("summary")}}
    cronjobs = ["hwlab-g14-branch-poller", "hwlab-v02-branch-poller"]
    original_crons = cronjob_suspend_states(cronjobs)
    before = du_size(REGISTRY_ROOT, 60) or 0
    gc_name = "hwlab-registry-gc-%s" % int(time.time())
    steps = []
    try:
        for name in original_crons:
            result = patch_cronjob_suspend(name, True)
            steps.append({"step": "suspend-cronjob", "name": name, "result": bounded(result)})
            if result["exitCode"] != 0:
                raise RuntimeError("failed to suspend cronjob %s" % name)
        idle_after_suspend = wait_no_active_hwlab_ci(180)
        steps.append({"step": "idle-after-suspend", "result": idle_after_suspend})
        if not idle_after_suspend.get("ok"):
            raise RuntimeError("refusing registry maintenance because hwlab-ci did not become idle after suspend")

        deleted_manifests = []
        for repo, digests in delete_manifests.items():
            encoded_repo = "/".join(urllib.parse.quote(part, safe="") for part in repo.split("/"))
            for digest in digests:
                try:
                    result = registry_request("DELETE", "/v2/%s/manifests/%s" % (encoded_repo, urllib.parse.quote(digest, safe=":")), {"Accept": "application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json"})
                    deleted_manifests.append({"repo": repo, "digest": digest, "status": result.get("status")})
                except urllib.error.HTTPError as exc:
                    if exc.code == 404:
                        deleted_manifests.append({"repo": repo, "digest": digest, "status": 404})
                    else:
                        raise
        steps.append({"step": "registry-api-delete-manifests", "count": len(deleted_manifests), "preview": deleted_manifests[:20]})

        scale_down = kctl(["-n", "hwlab-ci", "scale", "deploy", "hwlab-registry", "--replicas=0"], 60)
        steps.append({"step": "scale-registry-down", "result": bounded(scale_down)})
        if scale_down["exitCode"] != 0:
            raise RuntimeError("failed to scale registry down")
        waited_down = wait_registry_pod_count(0, 120)
        steps.append({"step": "wait-registry-down", "result": waited_down})
        if not waited_down.get("ok"):
            raise RuntimeError("registry pod did not scale down")

        deleted = []
        for row in delete_rows:
            path = os.path.abspath(str(row.get("path") or ""))
            if not path.startswith(REGISTRY_REPOSITORY_ROOT + "/") or "/_manifests/tags/" not in path:
                raise RuntimeError("refusing unexpected registry tag path: %s" % path)
            if not re.match(r"^[0-9a-f]{7,40}$", str(row.get("tag") or "")):
                raise RuntimeError("refusing unexpected registry tag name: %s" % row.get("tag"))
            if os.path.isdir(path) and not os.path.islink(path):
                shutil.rmtree(path)
                deleted.append({"repo": row.get("repo"), "tag": row.get("tag"), "digest": row.get("digest")})
        steps.append({"step": "delete-tag-directories", "count": len(deleted)})

        deleted_revisions = []
        for row in delete_revision_rows:
            path = os.path.abspath(str(row.get("path") or ""))
            digest_hex = registry_digest_hex(str(row.get("digest") or ""))
            if digest_hex is None:
                raise RuntimeError("refusing unexpected registry revision digest: %s" % row.get("digest"))
            if not path.startswith(REGISTRY_REPOSITORY_ROOT + "/") or "/_manifests/revisions/sha256/" not in path:
                raise RuntimeError("refusing unexpected registry revision path: %s" % path)
            if os.path.basename(path) != digest_hex:
                raise RuntimeError("refusing registry revision path/digest mismatch: %s" % path)
            if os.path.isdir(path) and not os.path.islink(path):
                shutil.rmtree(path)
                deleted_revisions.append({"repo": row.get("repo"), "digest": row.get("digest")})
        steps.append({"step": "delete-revision-directories", "count": len(deleted_revisions)})

        overrides = {
            "apiVersion": "v1",
            "spec": {
                "restartPolicy": "Never",
                "containers": [{
                    "name": "registry-gc",
                    "image": "registry:2.8.3",
                    "command": ["registry", "garbage-collect", "/etc/docker/registry/config.yml"],
                    "volumeMounts": [{"name": "storage", "mountPath": "/var/lib/registry"}],
                }],
                "volumes": [{"name": "storage", "hostPath": {"path": REGISTRY_ROOT, "type": "DirectoryOrCreate"}}],
            },
        }
        run_gc = kctl(["-n", "hwlab-ci", "run", gc_name, "--restart=Never", "--image=registry:2.8.3", "--overrides=%s" % json.dumps(overrides)], 60)
        steps.append({"step": "start-registry-gc-pod", "result": bounded(run_gc), "pod": gc_name})
        if run_gc["exitCode"] != 0:
            raise RuntimeError("failed to start registry GC pod")
        waited_gc = wait_pod_terminal(gc_name, 900)
        steps.append({"step": "wait-registry-gc", "result": waited_gc})
        logs = kctl(["-n", "hwlab-ci", "logs", gc_name], 120)
        steps.append({"step": "registry-gc-logs", "result": bounded(logs)})
        if not waited_gc.get("ok"):
            raise RuntimeError("registry GC pod did not complete successfully")
    finally:
        cleanup_gc = kctl(["-n", "hwlab-ci", "delete", "pod", gc_name, "--ignore-not-found=true"], 60)
        steps.append({"step": "delete-registry-gc-pod", "result": bounded(cleanup_gc)})
        scale_up = kctl(["-n", "hwlab-ci", "scale", "deploy", "hwlab-registry", "--replicas=%s" % int(deployment.get("replicas") or 1)], 60)
        steps.append({"step": "scale-registry-up", "result": bounded(scale_up)})
        rollout = kctl(["-n", "hwlab-ci", "rollout", "status", "deploy/hwlab-registry", "--timeout=180s"], 200)
        steps.append({"step": "wait-registry-rollout", "result": bounded(rollout)})
        for name, was_suspended in original_crons.items():
            restore = patch_cronjob_suspend(name, was_suspended)
            steps.append({"step": "restore-cronjob", "name": name, "suspend": was_suspended, "result": bounded(restore)})
    after = du_size(REGISTRY_ROOT, 60) or 0
    return {
        "reclaimedBytes": max(0, before - after),
        "commandOutput": {
            "registryPlan": plan.get("summary"),
            "deletedTagCount": len(delete_rows),
            "deletedRevisionCount": len(delete_revision_rows),
            "deletedManifestCount": sum(len(items) for items in delete_manifests.values()),
            "diskBeforeBytes": before,
            "diskAfterBytes": after,
            "steps": steps[-12:],
        },
    }

def execute_registry_garbage_collect_only():
    if registry_identity().get("mode") == "k8s-workload":
        return execute_k8s_registry_maintenance("garbage-collect")
    if PROVIDER_ID.upper() != "G14":
        raise RuntimeError("HWLAB registry garbage-collect is only supported on G14")
    deployment = registry_deployment_preflight()
    if not deployment.get("ok"):
        raise RuntimeError("registry deployment preflight failed: %s" % deployment.get("reason"))
    cronjobs = ["hwlab-g14-branch-poller", "hwlab-v02-branch-poller"]
    original_crons = cronjob_suspend_states(cronjobs)
    before = du_size(REGISTRY_ROOT, 60) or 0
    gc_name = "hwlab-registry-gc-%s" % int(time.time())
    steps = []
    try:
        for name in original_crons:
            result = patch_cronjob_suspend(name, True)
            steps.append({"step": "suspend-cronjob", "name": name, "result": bounded(result)})
            if result["exitCode"] != 0:
                raise RuntimeError("failed to suspend cronjob %s" % name)
        idle_after_suspend = wait_no_active_hwlab_ci(180)
        steps.append({"step": "idle-after-suspend", "result": idle_after_suspend})
        if not idle_after_suspend.get("ok"):
            raise RuntimeError("refusing registry maintenance because hwlab-ci did not become idle after suspend")

        scale_down = kctl(["-n", "hwlab-ci", "scale", "deploy", "hwlab-registry", "--replicas=0"], 60)
        steps.append({"step": "scale-registry-down", "result": bounded(scale_down)})
        if scale_down["exitCode"] != 0:
            raise RuntimeError("failed to scale registry down")
        waited_down = wait_registry_pod_count(0, 120)
        steps.append({"step": "wait-registry-down", "result": waited_down})
        if not waited_down.get("ok"):
            raise RuntimeError("registry pod did not scale down")

        overrides = {
            "apiVersion": "v1",
            "spec": {
                "restartPolicy": "Never",
                "containers": [{
                    "name": "registry-gc",
                    "image": "registry:2.8.3",
                    "command": ["registry", "garbage-collect", "/etc/docker/registry/config.yml"],
                    "volumeMounts": [{"name": "storage", "mountPath": "/var/lib/registry"}],
                }],
                "volumes": [{"name": "storage", "hostPath": {"path": REGISTRY_ROOT, "type": "DirectoryOrCreate"}}],
            },
        }
        run_gc = kctl(["-n", "hwlab-ci", "run", gc_name, "--restart=Never", "--image=registry:2.8.3", "--overrides=%s" % json.dumps(overrides)], 60)
        steps.append({"step": "start-registry-gc-pod", "result": bounded(run_gc), "pod": gc_name})
        if run_gc["exitCode"] != 0:
            raise RuntimeError("failed to start registry GC pod")
        waited_gc = wait_pod_terminal(gc_name, 900)
        steps.append({"step": "wait-registry-gc", "result": waited_gc})
        logs = kctl(["-n", "hwlab-ci", "logs", gc_name], 120)
        steps.append({"step": "registry-gc-logs", "result": bounded(logs)})
        if not waited_gc.get("ok"):
            raise RuntimeError("registry GC pod did not complete successfully")
    finally:
        cleanup_gc = kctl(["-n", "hwlab-ci", "delete", "pod", gc_name, "--ignore-not-found=true"], 60)
        steps.append({"step": "delete-registry-gc-pod", "result": bounded(cleanup_gc)})
        scale_up = kctl(["-n", "hwlab-ci", "scale", "deploy", "hwlab-registry", "--replicas=%s" % int(deployment.get("replicas") or 1)], 60)
        steps.append({"step": "scale-registry-up", "result": bounded(scale_up)})
        rollout = kctl(["-n", "hwlab-ci", "rollout", "status", "deploy/hwlab-registry", "--timeout=180s"], 200)
        steps.append({"step": "wait-registry-rollout", "result": bounded(rollout)})
        for name, was_suspended in original_crons.items():
            restore = patch_cronjob_suspend(name, was_suspended)
            steps.append({"step": "restore-cronjob", "name": name, "suspend": was_suspended, "result": bounded(restore)})
    after = du_size(REGISTRY_ROOT, 60) or 0
    return {
        "reclaimedBytes": max(0, before - after),
        "commandOutput": {
            "message": "official registry garbage-collect only; no additional tag deletion",
            "diskBeforeBytes": before,
            "diskAfterBytes": after,
            "steps": steps[-12:],
        },
    }

def start_registry_retention_job(mode):
    provider_slug = re.sub(r"[^a-z0-9]+", "-", PROVIDER_ID.lower()).strip("-") or "provider"
    job_id = "%s-registry-%s-%s" % (provider_slug, int(time.time()), os.getpid())
    paths = job_paths(job_id)
    started_at = now_iso()
    initial = {
        "ok": True,
        "action": "gc remote status",
        "providerId": PROVIDER_ID,
        "jobId": job_id,
        "status": "running",
        "kind": "registry-retention-gc" if mode == "retention" else "registry-garbage-collect",
        "mode": mode,
        "startedAt": started_at,
        "statePath": paths["state"],
        "logPath": paths["log"],
        "options": OPTIONS,
    }
    write_json_atomic(paths["state"], initial)
    pid = os.fork()
    if pid != 0:
        return {
            "status": "started",
            "reclaimedBytes": None,
            "commandOutput": {
                "jobId": job_id,
                "pid": pid,
                "statePath": paths["state"],
                "logPath": paths["log"],
                "statusCommand": "bun scripts/cli.ts gc remote %s status --job-id %s" % (PROVIDER_ID, job_id),
                "message": "registry maintenance is running as a detached remote job",
            },
        }

    try:
        os.setsid()
    except Exception:
        pass
    try:
        devnull = os.open(os.devnull, os.O_RDONLY)
        os.dup2(devnull, 0)
        os.close(devnull)
    except Exception:
        pass
    try:
        log_handle = open(paths["log"], "a", encoding="utf-8", buffering=1)
        os.dup2(log_handle.fileno(), 1)
        os.dup2(log_handle.fileno(), 2)
    except Exception:
        log_handle = None
    try:
        print("[%s] starting registry %s job %s" % (now_iso(), mode, job_id), flush=True)
        result = execute_registry_retention() if mode == "retention" else execute_registry_garbage_collect_only()
        payload = dict(initial)
        payload.update({
            "status": "succeeded",
            "finishedAt": now_iso(),
            "result": result,
            "diskAfter": df_snapshot(),
            "clusterAfter": cluster_preflight(),
        })
        write_json_atomic(paths["state"], payload)
        print("[%s] completed registry %s job %s" % (now_iso(), mode, job_id), flush=True)
        os._exit(0)
    except Exception as exc:
        payload = dict(initial)
        payload.update({
            "ok": False,
            "status": "failed",
            "finishedAt": now_iso(),
            "error": str(exc),
            "diskAfter": df_snapshot(),
            "clusterAfter": cluster_preflight(),
        })
        try:
            write_json_atomic(paths["state"], payload)
        except Exception:
            pass
        print("[%s] failed registry %s job %s: %s" % (now_iso(), mode, job_id, exc), flush=True)
        os._exit(1)
    finally:
        try:
            if log_handle:
                log_handle.close()
        except Exception:
            pass
