from datetime import datetime, timezone


def parse_docker_human_size(raw):
    raw = str(raw).split("(")[0].strip()
    match = re.match(r"^([0-9.]+)\s*([KMGT]?B)$", raw, re.I)
    if not match:
        return None
    multiplier = {"B": 1, "KB": 1000, "MB": 1000**2, "GB": 1000**3, "TB": 1000**4}.get(match.group(2).upper(), 1)
    return int(float(match.group(1)) * multiplier)


def parse_docker_build_cache(text):
    for line in text.splitlines():
        if not line.startswith("Build Cache"):
            continue
        match = re.match(r"^Build Cache\s+\S+\s+\S+\s+(\S+)\s+(\S+)", line.strip())
        if not match:
            continue
        size = parse_docker_human_size(match.group(1))
        reclaim = parse_docker_human_size(match.group(2))
        if size is None or reclaim is None:
            return None
        return {"sizeBytes": size, "reclaimableBytes": reclaim}
    return None


def build_cache_only_payload(observed_at):
    disk_before = df_snapshot()
    section = host_docker_gc_section("buildCachePrune")
    until = str(OPTIONS.get("buildCacheUntil") or section.get("until") or "24h")
    if not host_docker_gc_enabled("buildCachePrune"):
        return {"ok": False, "action": "gc remote %s" % ACTION, "providerId": PROVIDER_ID, "scope": "build-cache-only", "dryRun": ACTION == "plan", "mutation": False, "diagnosis": host_docker_gc_reason("buildCachePrune", "BuildKit cache GC is disabled by YAML")}
    system_df = command(["docker", "system", "df"], 8)
    if system_df["exitCode"] != 0:
        return {"ok": False, "action": "gc remote %s" % ACTION, "providerId": PROVIDER_ID, "scope": "build-cache-only", "dryRun": ACTION == "plan", "mutation": False, "diagnosis": bounded(system_df)}
    cache = parse_docker_build_cache(system_df["stdout"])
    candidate = None
    if cache is not None and cache["reclaimableBytes"] > 0:
        candidate = {"id": "docker-builder:prune", "kind": "docker-build-cache-prune", "risk": "low", "sizeBytes": cache["sizeBytes"], "estimatedReclaimBytes": cache["reclaimableBytes"], "action": {"command": ["docker", "builder", "prune", "--all", "--force", "--filter", "until=%s" % until]}}
    payload = {"ok": True, "action": "gc remote %s" % ACTION, "providerId": PROVIDER_ID, "scope": "build-cache-only", "configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.hostDockerGc.buildCachePrune" % PROVIDER_ID, "dryRun": ACTION == "plan", "mutation": ACTION == "run" and candidate is not None, "observedAt": observed_at, "diskBefore": disk_before, "until": until, "candidates": [candidate] if candidate else []}
    if ACTION == "run" and candidate is not None:
        payload["result"] = execute(candidate)
        payload["diskAfter"] = df_snapshot()
    return payload


def docker_containers():
    ps = command(["docker", "ps", "-qa", "--no-trunc"], 5)
    if ps["exitCode"] != 0 or not ps["stdout"].strip():
        return []
    ids = ps["stdout"].split()
    inspect = command(["docker", "inspect"] + ids, 10)
    if inspect["exitCode"] != 0 or not inspect["stdout"].strip():
        return []
    try:
        data = json.loads(inspect["stdout"])
    except Exception:
        return []
    rows = []
    for item in data:
        cfg = item.get("Config") or {}
        rows.append({"id": str(item.get("Id") or ""), "name": str(item.get("Name") or "").lstrip("/"), "image": str(cfg.get("Image") or item.get("Image") or ""), "imageId": str(item.get("Image") or ""), "logPath": str(item.get("LogPath") or "")})
    return [row for row in rows if row["id"]]


def parse_docker_created(raw):
    value = str(raw or "").strip()
    if not value:
        return None
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc).timestamp()
    except Exception:
        return None


def collect_host_docker_image_candidates(observed_at):
    global HOST_DOCKER_IMAGE_SCAN
    section = host_docker_gc_section("imagePrune")
    min_age_hours = config_float(section, "minAgeHours", 168.0, minimum=0.0)
    max_delete = config_int(section, "maxDeletePerRun", 20, minimum=1, maximum=500)
    preview_limit = config_int(section, "previewLimit", max_delete, minimum=1, maximum=500)
    image_ids = command(["docker", "image", "ls", "-aq", "--no-trunc"], 10)
    if image_ids["exitCode"] != 0:
        HOST_DOCKER_IMAGE_SCAN = {"ok": False, "error": "docker-image-list-failed", "command": bounded(image_ids)}
        return []
    ids = sorted(set(image_ids["stdout"].split()))
    referenced = set(row.get("imageId") for row in docker_containers() if row.get("imageId"))
    if not ids:
        HOST_DOCKER_IMAGE_SCAN = {"ok": True, "minAgeHours": min_age_hours, "maxDeletePerRun": max_delete, "imageCount": 0, "referencedImageCount": len(referenced), "eligibleCount": 0, "selectedCount": 0}
        return []
    inspected = command(["docker", "image", "inspect"] + ids, 30)
    if inspected["exitCode"] != 0:
        HOST_DOCKER_IMAGE_SCAN = {"ok": False, "error": "docker-image-inspect-failed", "command": bounded(inspected)}
        return []
    try:
        images = json.loads(inspected["stdout"])
    except Exception as exc:
        HOST_DOCKER_IMAGE_SCAN = {"ok": False, "error": "docker-image-inspect-invalid-json", "details": str(exc)}
        return []
    cutoff = time.time() - min_age_hours * 3600.0
    eligible = []
    protected_rows = []
    for image in images:
        image_id = str(image.get("Id") or "")
        created_at = str(image.get("Created") or "")
        created_epoch = parse_docker_created(created_at)
        tags = [str(item) for item in (image.get("RepoTags") or []) if item]
        if image_id in referenced:
            protected_rows.append({"imageId": image_id, "tags": tags, "reason": "referenced-by-container"})
            continue
        if created_epoch is None or created_epoch > cutoff:
            protected_rows.append({"imageId": image_id, "tags": tags, "reason": "younger-than-min-age-or-age-unknown", "createdAt": created_at})
            continue
        size = int(image.get("Size") or 0)
        eligible.append({"id": "docker-image:%s" % image_id, "kind": "docker-image-delete", "risk": "medium", "description": "Delete old host Docker image unreferenced by any container", "imageId": image_id, "tags": tags, "createdAt": created_at, "ageHours": round(max(0.0, (time.time() - created_epoch) / 3600.0), 1), "minAgeHours": min_age_hours, "sizeBytes": size, "estimatedReclaimBytes": size, "protection": {"allContainerStatesInspected": True, "unreferencedByAnyContainer": True}, "action": {"command": ["docker", "image", "rm", image_id]}})
    eligible.sort(key=lambda item: (item.get("createdAt") or "", item.get("imageId") or ""))
    selected = eligible[:max_delete]
    HOST_DOCKER_IMAGE_SCAN = {"ok": True, "configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.hostDockerGc.imagePrune" % PROVIDER_ID, "minAgeHours": min_age_hours, "maxDeletePerRun": max_delete, "imageCount": len(images), "referencedImageCount": len(referenced), "protectedCount": len(protected_rows), "eligibleCount": len(eligible), "selectedCount": len(selected), "protectedPreview": protected_rows[:preview_limit]}
    return selected


def host_docker_only_payload(observed_at):
    disk_before = df_snapshot()
    candidates = []
    build_section = host_docker_gc_section("buildCachePrune")
    build_until = str(OPTIONS.get("buildCacheUntil") or build_section.get("until") or "24h")
    if host_docker_gc_enabled("buildCachePrune"):
        system_df = command(["docker", "system", "df"], 8)
        if system_df["exitCode"] != 0:
            return {"ok": False, "action": "gc remote %s" % ACTION, "providerId": PROVIDER_ID, "scope": "host-docker-only", "dryRun": ACTION == "plan", "mutation": False, "diagnosis": bounded(system_df)}
        cache = parse_docker_build_cache(system_df["stdout"])
        if cache is not None and cache["reclaimableBytes"] > 0:
            candidates.append({"id": "docker-builder:prune", "kind": "docker-build-cache-prune", "risk": "low", "description": "Prune Docker BuildKit cache unused for %s" % build_until, "sizeBytes": cache["sizeBytes"], "estimatedReclaimBytes": cache["reclaimableBytes"], "action": {"command": ["docker", "builder", "prune", "--all", "--force", "--filter", "until=%s" % build_until]}})
    if host_docker_gc_enabled("imagePrune", False):
        candidates.extend(collect_host_docker_image_candidates(observed_at))
    visible = visible_items(candidates)
    payload = {"ok": HOST_DOCKER_IMAGE_SCAN.get("ok") is not False, "action": "gc remote %s" % ACTION, "providerId": PROVIDER_ID, "scope": "host-docker-only", "configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.hostDockerGc" % PROVIDER_ID, "dryRun": ACTION == "plan", "mutation": ACTION == "run" and bool(visible), "observedAt": observed_at, "diskBefore": disk_before, "buildCacheUntil": build_until, "imageRetention": HOST_DOCKER_IMAGE_SCAN, "summary": summarize(candidates, visible, disk_before), "candidates": visible, "protected": {"containerStates": ["running", "stopped", "created"], "referencedImages": "excluded-and-rechecked-before-delete", "containers": "never-deleted", "volumes": "never-deleted"}}
    if ACTION == "run":
        results = []
        for candidate in visible:
            try:
                execution = execute(candidate)
                results.append({**candidate, "status": "succeeded", "reclaimedBytes": execution.get("reclaimedBytes"), "commandOutput": execution.get("commandOutput")})
            except Exception as exc:
                results.append({**candidate, "status": "failed", "reclaimedBytes": None, "error": str(exc)})
                break
        payload["results"] = results
        payload["ok"] = payload["ok"] and all(item.get("status") == "succeeded" for item in results)
        payload["diskAfter"] = df_snapshot()
        if payload["diskBefore"] and payload["diskAfter"]:
            payload["actualDiskReclaimBytes"] = max(0, payload["diskAfter"]["availableBytes"] - payload["diskBefore"]["availableBytes"])
    return payload
