# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Cleanup of empty leftover quant folders from interrupted split downloads."""

import errno
from pathlib import Path
from types import SimpleNamespace

from hub.schemas.inventory import GgufVariantDetail, GgufVariantsResponse
from hub.services.models import deletion, gguf_variants
from hub.utils import gguf


def _make_snapshot(root: Path) -> Path:
    snap = root / "snapshots" / "rev0"
    (snap / "UD-IQ1_M").mkdir(parents = True)
    (snap / "UD-IQ1_M" / "GLM-UD-IQ1_M-00001-of-00002.gguf").write_bytes(b"x")
    (snap / "UD-IQ1_M" / "GLM-UD-IQ1_M-00002-of-00002.gguf").write_bytes(b"y")
    (snap / "UD-IQ1_S").mkdir(parents = True)  # empty leftover
    return snap


def test_list_empty_gguf_variant_dirs_finds_empty_leftover(tmp_path, monkeypatch):
    snap = _make_snapshot(tmp_path)
    monkeypatch.setattr(gguf, "iter_hf_cache_snapshots", lambda repo_id: iter([snap]))
    assert gguf.list_empty_gguf_variant_dirs("org/Repo-GGUF") == {"UD-IQ1_S"}


def test_list_empty_excludes_quant_with_files_in_another_snapshot(tmp_path, monkeypatch):
    snap1 = tmp_path / "s1" / "snapshots" / "rev"
    (snap1 / "UD-IQ1_S").mkdir(parents = True)  # empty here
    snap2 = tmp_path / "s2" / "snapshots" / "rev"
    (snap2 / "UD-IQ1_S").mkdir(parents = True)
    (snap2 / "UD-IQ1_S" / "m-UD-IQ1_S-00001-of-00001.gguf").write_bytes(b"z")  # has shards
    monkeypatch.setattr(gguf, "iter_hf_cache_snapshots", lambda repo_id: iter([snap1, snap2]))
    assert gguf.list_empty_gguf_variant_dirs("org/Repo-GGUF") == set()


def test_list_empty_ignores_non_quant_dirs(tmp_path, monkeypatch):
    snap = tmp_path / "snapshots" / "rev"
    (snap / "not-a-quant").mkdir(parents = True)  # empty but not a quant label
    monkeypatch.setattr(gguf, "iter_hf_cache_snapshots", lambda repo_id: iter([snap]))
    assert gguf.list_empty_gguf_variant_dirs("org/Repo-GGUF") == set()


def test_remove_empty_variant_dirs_removes_only_empty_match(tmp_path):
    snap = _make_snapshot(tmp_path)
    repo = SimpleNamespace(repo_path = str(tmp_path))
    removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_S")
    assert removed == 1
    assert failures == []
    assert not (snap / "UD-IQ1_S").exists()
    assert (snap / "UD-IQ1_M").is_dir()


def test_remove_empty_variant_dirs_never_touches_populated_folder(tmp_path):
    snap = _make_snapshot(tmp_path)
    repo = SimpleNamespace(repo_path = str(tmp_path))
    removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_M")
    assert removed == 0
    assert failures == []
    assert len(list((snap / "UD-IQ1_M").iterdir())) == 2


def test_remove_empty_variant_dirs_surfaces_real_failure(tmp_path, monkeypatch):
    _make_snapshot(tmp_path)
    repo = SimpleNamespace(repo_path = str(tmp_path))

    def _denied(self):
        raise OSError(errno.EACCES, "permission denied")

    monkeypatch.setattr(Path, "rmdir", _denied)
    removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_S")
    assert removed == 0
    assert len(failures) == 1


def test_remove_empty_variant_dirs_ignores_concurrent_refill(tmp_path, monkeypatch):
    _make_snapshot(tmp_path)
    repo = SimpleNamespace(repo_path = str(tmp_path))

    def _refilled(self):
        raise OSError(errno.ENOTEMPTY, "directory not empty")

    monkeypatch.setattr(Path, "rmdir", _refilled)
    removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_S")
    assert removed == 0
    assert failures == []


def test_mark_empty_dir_cleanables_appends_unlisted(monkeypatch):
    monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"})
    resp = GgufVariantsResponse(
        repo_id = "org/Repo-GGUF",
        variants = [GgufVariantDetail(filename = "m-UD-IQ1_M.gguf", quant = "UD-IQ1_M", downloaded = True)],
    )
    out = gguf_variants._mark_empty_dir_cleanables("org/Repo-GGUF", resp)
    by_q = {v.quant: v for v in out.variants}
    assert by_q["UD-IQ1_M"].downloaded is True
    assert by_q["UD-IQ1_S"].partial is True and by_q["UD-IQ1_S"].downloaded is False


def test_mark_empty_dir_cleanables_flips_listed_variant(monkeypatch):
    monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"})
    resp = GgufVariantsResponse(
        repo_id = "org/Repo-GGUF",
        variants = [GgufVariantDetail(filename = "m-UD-IQ1_S.gguf", quant = "UD-IQ1_S")],
    )
    out = gguf_variants._mark_empty_dir_cleanables("org/Repo-GGUF", resp)
    assert len(out.variants) == 1
    assert out.variants[0].partial is True


def _force_compute_to_raise(monkeypatch):
    # Drive _compute() down its remote path, fail metadata, and have both cache
    # fallbacks miss so the original error re-raises.
    def _boom(*a, **k):
        raise RuntimeError("offline")

    monkeypatch.setattr(gguf_variants, "list_gguf_variants", _boom, raising = False)
    monkeypatch.setattr(
        gguf_variants, "list_gguf_variants_from_hf_cache", lambda repo_id: None, raising = False
    )
    monkeypatch.setattr(
        gguf_variants, "list_partial_gguf_variants_from_state", lambda repo_id: None, raising = False
    )


def test_get_variants_surfaces_cleanable_when_metadata_fails(monkeypatch):
    # Offline / model_info fails and only an empty leftover folder is cached:
    # the cleanable must still be returned instead of the error propagating.
    import asyncio

    _force_compute_to_raise(monkeypatch)
    monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"})

    resp = asyncio.run(
        gguf_variants.get_gguf_variants_response(
            "org/Repo-GGUF", prefer_local_cache = False, hf_token = None
        )
    )
    by_q = {v.quant: v for v in resp.variants}
    assert "UD-IQ1_S" in by_q
    assert by_q["UD-IQ1_S"].partial is True and by_q["UD-IQ1_S"].downloaded is False


def test_get_variants_reraises_when_no_cleanable(monkeypatch):
    # Offline with nothing cleanable: original error must propagate (as HTTP).
    import asyncio

    from fastapi import HTTPException

    _force_compute_to_raise(monkeypatch)
    monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: set())

    try:
        asyncio.run(
            gguf_variants.get_gguf_variants_response(
                "org/Repo-GGUF", prefer_local_cache = False, hf_token = None
            )
        )
        raised = False
    except (HTTPException, RuntimeError):
        raised = True
    assert raised
