import asyncio
import json
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict

import pytest

from py.config import config
from py.services import model_scanner as model_scanner_module
from py.services.model_cache import ModelCache
from py.services.model_hash_index import ModelHashIndex
from py.services.model_scanner import CacheBuildResult, ModelScanner
from py.services.recipe_scanner import RecipeScanner
from py.services import settings_manager as settings_manager_module
from py.utils.models import BaseModelMetadata
from py.utils.utils import calculate_recipe_fingerprint


async def _wait_for_resort(scanner: RecipeScanner) -> None:
    """Deterministically await any pending background cache resort.

    Resort runs in a thread-pool executor; a bare sleep(0) races it.
    """
    tasks = list(getattr(scanner, "_resort_tasks", None) or [])
    if tasks:
        await asyncio.gather(*tasks, return_exceptions=True)


class StubHashIndex:
    def __init__(self) -> None:
        self._hash_to_path: dict[str, str] = {}

    def get_path(self, hash_value: str) -> str | None:
        return self._hash_to_path.get(hash_value)


class StubLoraScanner:
    def __init__(self) -> None:
        self._hash_index = StubHashIndex()
        self._hash_meta: dict[str, dict[str, str]] = {}
        self._models_by_name: dict[str, Dict[str, Any]] = {}
        self._cache = SimpleNamespace(raw_data=[], version_index={})

    async def get_cached_data(self):
        return self._cache

    def has_hash(self, hash_value: str) -> bool:
        return hash_value.lower() in self._hash_meta

    def get_preview_url_by_hash(self, hash_value: str) -> str:
        meta = self._hash_meta.get(hash_value.lower())
        return meta.get("preview_url", "") if meta else ""

    def get_path_by_hash(self, hash_value: str) -> str | None:
        meta = self._hash_meta.get(hash_value.lower())
        return meta.get("path") if meta else None

    async def get_model_info_by_name(
        self,
        name: str,
        *,
        require_unique: bool = False,
        base_model: str | None = None,
    ):
        if require_unique or base_model:
            matches = ModelScanner.find_matching_models(
                self._cache.raw_data,
                name,
                base_model=base_model,
                extensions={".safetensors"},
            )
            if require_unique and len(matches) != 1:
                return None
            return matches[0] if matches else None
        return self._models_by_name.get(name)

    async def find_models_by_name(self, name: str, *, base_model: str | None = None):
        return ModelScanner.find_matching_models(
            self._cache.raw_data,
            name,
            base_model=base_model,
            extensions={".safetensors"},
        )

    def register_model(self, name: str, info: Dict[str, Any]) -> None:
        self._models_by_name[name] = info
        hash_value = (info.get("sha256") or "").lower()
        version_id = info.get("civitai", {}).get("id")
        if hash_value:
            self._hash_meta[hash_value] = {
                "path": info.get("file_path", ""),
                "preview_url": info.get("preview_url", ""),
            }
            self._hash_index._hash_to_path[hash_value] = info.get("file_path", "")
        if version_id is not None:
            self._cache.version_index[int(version_id)] = {
                "file_path": info.get("file_path", ""),
                "sha256": hash_value,
                "preview_url": info.get("preview_url", ""),
                "civitai": info.get("civitai", {}),
            }
        self._cache.raw_data.append(
            {
                "sha256": info.get("sha256", ""),
                "path": info.get("file_path", ""),
                "civitai": info.get("civitai", {}),
            }
        )


@pytest.fixture
def recipe_scanner(tmp_path: Path, monkeypatch):
    RecipeScanner._instance = None
    settings_manager_module.reset_settings_manager()
    monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
    stub = StubLoraScanner()
    scanner = RecipeScanner(lora_scanner=stub)  # pyright: ignore[reportArgumentType]

    async def _init():
        await scanner.refresh_cache(force=True)
        # Wait for FTS index build to finish — asyncio.run()
        # cancels background tasks on return, so we must await it here.
        if scanner._fts_index_task:
            await scanner._fts_index_task

    asyncio.run(_init())
    yield scanner, stub
    RecipeScanner._instance = None
    settings_manager_module.reset_settings_manager()


@pytest.mark.asyncio
async def test_local_lora_lookup_requires_unambiguous_name_and_matching_base_model(recipe_scanner):
    scanner, stub = recipe_scanner
    models = [
        {
            "file_name": "style.safetensors",
            "folder": "sd15",
            "file_path": "/models/loras/sd15/style.safetensors",
            "sha256": "a" * 64,
            "base_model": "SD 1.5",
        },
        {
            "file_name": "style.safetensors",
            "folder": "sdxl",
            "file_path": "/models/loras/sdxl/style.safetensors",
            "sha256": "b" * 64,
            "base_model": "SDXL 1.0",
        },
    ]
    stub._cache.raw_data = models
    stub._hash_meta["b" * 64] = {"path": models[1]["file_path"]}

    assert await scanner.get_local_lora("style") is None
    assert await scanner.get_local_lora("style", "SDXL 1.0") is models[1]
    assert await scanner.get_local_lora("sdxl/style.safetensors", "SDXL 1.0") is models[1]
    # The lora scanner only indexes .safetensors, so a .pt name must not be
    # stripped into a cross-extension match.
    assert await scanner.get_local_lora("sdxl/style.pt", "SDXL 1.0") is None
    assert await scanner.get_local_lora("sdxl/style.safetensors", "SD 1.5") is None
    assert await scanner.get_local_lora("other/style.safetensors") is None
    assert await scanner.get_local_lora_by_hash("b" * 64) is models[1]


def test_recipes_dir_uses_custom_settings_path(tmp_path: Path, monkeypatch):
    RecipeScanner._instance = None
    settings_manager_module.reset_settings_manager()

    settings_path = tmp_path / "settings.json"
    custom_recipes = tmp_path / "custom" / ".." / "custom_recipes"

    monkeypatch.setattr(
        "py.services.settings_manager.ensure_settings_file",
        lambda logger=None: str(settings_path),
    )
    monkeypatch.setattr(config, "loras_roots", [str(tmp_path / "loras-root")])

    manager = settings_manager_module.get_settings_manager()
    manager.set("recipes_path", str(custom_recipes))

    scanner = RecipeScanner(lora_scanner=StubLoraScanner())  # pyright: ignore[reportArgumentType]
    resolved = scanner.recipes_dir

    assert resolved == str((tmp_path / "custom_recipes").resolve())
    assert Path(resolved).is_dir()

    RecipeScanner._instance = None
    settings_manager_module.reset_settings_manager()


def test_recipes_dir_falls_back_to_first_lora_root(tmp_path: Path, monkeypatch):
    RecipeScanner._instance = None
    settings_manager_module.reset_settings_manager()

    monkeypatch.setattr(config, "loras_roots", [str(tmp_path / "alpha")])

    scanner = RecipeScanner(lora_scanner=StubLoraScanner())  # pyright: ignore[reportArgumentType]
    resolved = scanner.recipes_dir

    assert resolved == str(tmp_path / "alpha" / "recipes")
    assert Path(resolved).is_dir()

    RecipeScanner._instance = None
    settings_manager_module.reset_settings_manager()


async def test_add_recipe_during_concurrent_reads(recipe_scanner):
    scanner, _ = recipe_scanner

    initial_recipe = {
        "id": "one",
        "file_path": "path/a.png",
        "title": "First",
        "modified": 1.0,
        "created_date": 1.0,
        "loras": [],
    }
    await scanner.add_recipe(initial_recipe)

    new_recipe = {
        "id": "two",
        "file_path": "path/b.png",
        "title": "Second",
        "modified": 2.0,
        "created_date": 2.0,
        "loras": [],
    }

    async def reader_task():
        for _ in range(5):
            cache = await scanner.get_cached_data()
            _ = [item["id"] for item in cache.raw_data]
            await asyncio.sleep(0)

    await asyncio.gather(reader_task(), reader_task(), scanner.add_recipe(new_recipe))
    # Wait a bit longer for the thread-pool resort to complete
    await asyncio.sleep(0.1)
    cache = await scanner.get_cached_data()

    assert {item["id"] for item in cache.raw_data} == {"one", "two"}
    assert len(cache.sorted_by_name) == len(cache.raw_data)


async def test_remove_recipe_during_reads(recipe_scanner):
    scanner, _ = recipe_scanner

    recipe_ids = ["alpha", "beta", "gamma"]
    for index, recipe_id in enumerate(recipe_ids):
        await scanner.add_recipe(
            {
                "id": recipe_id,
                "file_path": f"path/{recipe_id}.png",
                "title": recipe_id,
                "modified": float(index),
                "created_date": float(index),
                "loras": [],
            }
        )

    async def reader_task():
        for _ in range(5):
            cache = await scanner.get_cached_data()
            _ = list(cache.sorted_by_date)
            await asyncio.sleep(0)

    await asyncio.gather(reader_task(), scanner.remove_recipe("beta"))
    await asyncio.sleep(0)
    cache = await scanner.get_cached_data()

    assert {item["id"] for item in cache.raw_data} == {"alpha", "gamma"}


async def test_update_lora_entry_updates_cache_and_file(tmp_path: Path, recipe_scanner):
    scanner, stub = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    recipe_id = "recipe-1"
    recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
    recipe_data = {
        "id": recipe_id,
        "file_path": str(tmp_path / "image.png"),
        "title": "Original",
        "modified": 0.0,
        "created_date": 0.0,
        "loras": [
            {
                "file_name": "old",
                "strength": 1.0,
                "hash": "",
                "isDeleted": True,
                "exclude": True,
            },
        ],
    }
    recipe_path.write_text(json.dumps(recipe_data))

    await scanner.add_recipe(dict(recipe_data))

    target_hash = "abc123"
    target_info = {
        "sha256": target_hash,
        "file_path": str(tmp_path / "loras" / "target.safetensors"),
        "preview_url": "preview.png",
        "civitai": {"id": 42, "name": "v1", "model": {"name": "Target"}},
    }
    stub.register_model("target", target_info)

    updated_recipe, updated_lora = await scanner.update_lora_entry(
        recipe_id,
        0,
        target_name="target",
        target_lora=target_info,
    )

    assert updated_lora["inLibrary"] is True
    assert updated_lora["localPath"] == target_info["file_path"]
    assert updated_lora["hash"] == target_hash

    with recipe_path.open("r", encoding="utf-8") as file_obj:
        persisted = json.load(file_obj)

    expected_fingerprint = calculate_recipe_fingerprint(persisted["loras"])
    assert persisted["fingerprint"] == expected_fingerprint

    cache = await scanner.get_cached_data()
    cached_recipe = next(item for item in cache.raw_data if item["id"] == recipe_id)
    assert cached_recipe["loras"][0]["hash"] == target_hash
    assert cached_recipe["fingerprint"] == expected_fingerprint


async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_scanner):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    recipe_id = "hash-invalid-1"
    recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
    recipe_data = {
        "id": recipe_id,
        "file_path": str(tmp_path / "image.png"),
        "title": "Hash invalid",
        "modified": 0.0,
        "created_date": 0.0,
        "loras": [
            {
                "file_name": "Daphne Blake Cosplay_v1",
                "strength": 1.0,
                "hash": "a2a12bfa01",
            },
        ],
    }
    recipe_path.write_text(json.dumps(recipe_data))
    await scanner.add_recipe(dict(recipe_data))

    updated_recipe, updated_lora = await scanner.set_lora_entry_hash_invalid(
        recipe_id, 0, True
    )

    assert updated_lora["hashInvalid"] is True
    assert updated_recipe["loras"][0]["hashInvalid"] is True
    with recipe_path.open("r", encoding="utf-8") as file_obj:
        persisted = json.load(file_obj)
    assert persisted["loras"][0]["hashInvalid"] is True
    assert persisted["loras"][0]["hash"] == "a2a12bfa01"

    cache = await scanner.get_cached_data()
    cached_recipe = next(item for item in cache.raw_data if item["id"] == recipe_id)
    assert cached_recipe["loras"][0]["hashInvalid"] is True

    _, cleared_lora = await scanner.set_lora_entry_hash_invalid(recipe_id, 0, False)
    assert cleared_lora["hashInvalid"] is False


@pytest.mark.asyncio
async def test_load_recipe_rewrites_missing_image_path(tmp_path: Path, recipe_scanner):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    recipe_id = "moved"
    old_root = tmp_path / "old_root"
    old_path = old_root / "recipes" / f"{recipe_id}.webp"
    recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
    current_image = recipes_dir / f"{recipe_id}.webp"
    current_image.write_bytes(b"image-bytes")

    recipe_data = {
        "id": recipe_id,
        "file_path": str(old_path),
        "title": "Relocated",
        "modified": 0.0,
        "created_date": 0.0,
        "loras": [],
    }
    recipe_path.write_text(json.dumps(recipe_data))

    loaded = await scanner._load_recipe_file(str(recipe_path))

    expected_path = os.path.normpath(str(current_image))
    assert loaded["file_path"] == expected_path

    persisted = json.loads(recipe_path.read_text())
    assert persisted["file_path"] == expected_path


@pytest.mark.asyncio
async def test_load_recipe_upgrades_string_checkpoint(tmp_path: Path, recipe_scanner):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    recipe_id = "legacy-checkpoint"
    image_path = recipes_dir / f"{recipe_id}.webp"
    recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
    recipe_path.write_text(
        json.dumps(
            {
                "id": recipe_id,
                "file_path": str(image_path),
                "title": "Legacy",
                "modified": 0.0,
                "created_date": 0.0,
                "loras": [],
                "checkpoint": "sd15.safetensors",
            }
        )
    )

    loaded = await scanner._load_recipe_file(str(recipe_path))

    assert isinstance(loaded["checkpoint"], dict)
    assert loaded["checkpoint"]["name"] == "sd15.safetensors"
    assert loaded["checkpoint"]["file_name"] == "sd15"


# ---------------------------------------------------------------------------
# has_workflow detection (plan 3.1)
# ---------------------------------------------------------------------------


def _recipe_json(recipes_dir: Path, recipe_id: str, image_path: Path, **extra: Any) -> Path:
    recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
    data: Dict[str, Any] = {
        "id": recipe_id,
        "file_path": str(image_path),
        "title": recipe_id,
        "modified": 0.0,
        "created_date": 0.0,
        "loras": [],
    }
    data.update(extra)
    recipe_path.write_text(json.dumps(data))
    return recipe_path


def _mock_metadata(monkeypatch, workflow=None, raises=False):
    def _load_structured_metadata(_image_path):
        if raises:
            raise RuntimeError("metadata parse failure")
        return {
            "parameters": None,
            "prompt": "a test prompt",
            "workflow": workflow,
            "comment": None,
        }

    monkeypatch.setattr(
        "py.services.recipe_scanner.ExifUtils._load_structured_metadata",
        _load_structured_metadata,
    )


@pytest.mark.asyncio
async def test_load_recipe_detects_embedded_workflow(tmp_path: Path, recipe_scanner, monkeypatch):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    image_path = recipes_dir / "with-workflow.webp"
    image_path.write_bytes(b"fake-webp")
    recipe_path = _recipe_json(recipes_dir, "with-workflow", image_path)

    _mock_metadata(monkeypatch, workflow='{"nodes": []}')

    loaded = await scanner._load_recipe_file(str(recipe_path))

    assert loaded["has_workflow"] is True
    persisted = json.loads(recipe_path.read_text())
    assert persisted["has_workflow"] is True


@pytest.mark.asyncio
async def test_load_recipe_detects_no_workflow(tmp_path: Path, recipe_scanner, monkeypatch):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    image_path = recipes_dir / "without-workflow.webp"
    image_path.write_bytes(b"fake-webp")
    recipe_path = _recipe_json(recipes_dir, "without-workflow", image_path)

    _mock_metadata(monkeypatch, workflow=None)

    loaded = await scanner._load_recipe_file(str(recipe_path))

    assert loaded["has_workflow"] is False
    persisted = json.loads(recipe_path.read_text())
    assert persisted["has_workflow"] is False


@pytest.mark.asyncio
async def test_load_recipe_metadata_exception_yields_false(
    tmp_path: Path, recipe_scanner, monkeypatch
):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    image_path = recipes_dir / "broken-meta.webp"
    image_path.write_bytes(b"fake-webp")
    recipe_path = _recipe_json(recipes_dir, "broken-meta", image_path)

    _mock_metadata(monkeypatch, raises=True)

    loaded = await scanner._load_recipe_file(str(recipe_path))

    assert loaded["has_workflow"] is False


@pytest.mark.asyncio
async def test_load_recipe_missing_image_yields_false(
    tmp_path: Path, recipe_scanner, monkeypatch
):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    image_path = recipes_dir / "missing-image.webp"
    recipe_path = _recipe_json(recipes_dir, "missing-image", image_path)

    _mock_metadata(monkeypatch, workflow='{"nodes": []}')

    loaded = await scanner._load_recipe_file(str(recipe_path))

    assert loaded["has_workflow"] is False


@pytest.mark.asyncio
async def test_load_recipe_keeps_existing_has_workflow(
    tmp_path: Path, recipe_scanner, monkeypatch
):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    image_path = recipes_dir / "pre-recorded.webp"
    image_path.write_bytes(b"fake-webp")
    recipe_path = _recipe_json(recipes_dir, "pre-recorded", image_path, has_workflow=True)

    _mock_metadata(monkeypatch, workflow=None)

    loaded = await scanner._load_recipe_file(str(recipe_path))

    assert loaded["has_workflow"] is True
    persisted = json.loads(recipe_path.read_text())
    assert persisted["has_workflow"] is True


def test_load_recipe_file_sync_detects_embedded_workflow(tmp_path: Path, monkeypatch):
    RecipeScanner._instance = None
    settings_manager_module.reset_settings_manager()
    monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
    scanner = RecipeScanner(lora_scanner=StubLoraScanner())  # pyright: ignore[reportArgumentType]
    try:
        recipes_dir = Path(config.loras_roots[0]) / "recipes"
        recipes_dir.mkdir(parents=True, exist_ok=True)

        image_path = recipes_dir / "sync-workflow.webp"
        image_path.write_bytes(b"fake-webp")
        recipe_path = _recipe_json(recipes_dir, "sync-workflow", image_path)

        _mock_metadata(monkeypatch, workflow='{"nodes": []}')

        loaded = scanner._load_recipe_file_sync(str(recipe_path))

        assert loaded["has_workflow"] is True
        persisted = json.loads(recipe_path.read_text())
        assert persisted["has_workflow"] is True
    finally:
        RecipeScanner._instance = None
        settings_manager_module.reset_settings_manager()


def test_load_recipe_file_sync_detects_no_workflow(tmp_path: Path, monkeypatch):
    RecipeScanner._instance = None
    settings_manager_module.reset_settings_manager()
    monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
    scanner = RecipeScanner(lora_scanner=StubLoraScanner())  # pyright: ignore[reportArgumentType]
    try:
        recipes_dir = Path(config.loras_roots[0]) / "recipes"
        recipes_dir.mkdir(parents=True, exist_ok=True)

        image_path = recipes_dir / "sync-no-workflow.webp"
        image_path.write_bytes(b"fake-webp")
        recipe_path = _recipe_json(recipes_dir, "sync-no-workflow", image_path)

        _mock_metadata(monkeypatch, workflow=None)

        loaded = scanner._load_recipe_file_sync(str(recipe_path))

        assert loaded["has_workflow"] is False
        persisted = json.loads(recipe_path.read_text())
        assert persisted["has_workflow"] is False
    finally:
        RecipeScanner._instance = None
        settings_manager_module.reset_settings_manager()


def test_load_recipe_file_sync_metadata_exception_yields_false(
    tmp_path: Path, monkeypatch
):
    RecipeScanner._instance = None
    settings_manager_module.reset_settings_manager()
    monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
    scanner = RecipeScanner(lora_scanner=StubLoraScanner())  # pyright: ignore[reportArgumentType]
    try:
        recipes_dir = Path(config.loras_roots[0]) / "recipes"
        recipes_dir.mkdir(parents=True, exist_ok=True)

        image_path = recipes_dir / "sync-broken-meta.webp"
        image_path.write_bytes(b"fake-webp")
        recipe_path = _recipe_json(recipes_dir, "sync-broken-meta", image_path)

        _mock_metadata(monkeypatch, raises=True)

        loaded = scanner._load_recipe_file_sync(str(recipe_path))

        assert loaded["has_workflow"] is False
    finally:
        RecipeScanner._instance = None
        settings_manager_module.reset_settings_manager()


@pytest.mark.asyncio
async def test_get_paginated_data_normalizes_legacy_checkpoint(recipe_scanner):
    scanner, _ = recipe_scanner
    image_path = Path(config.loras_roots[0]) / "legacy.webp"
    await scanner.add_recipe(
        {
            "id": "legacy-checkpoint",
            "file_path": str(image_path),
            "title": "Legacy",
            "modified": 0.0,
            "created_date": 0.0,
            "loras": [],
            "checkpoint": ["legacy.safetensors"],
        }
    )
    await asyncio.sleep(0)
    await _wait_for_resort(scanner)

    result = await scanner.get_paginated_data(page=1, page_size=5)

    checkpoint = result["items"][0]["checkpoint"]
    assert checkpoint["name"] == "legacy.safetensors"
    assert checkpoint["file_name"] == "legacy"

@pytest.mark.asyncio
async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
    scanner, _ = recipe_scanner
    image_path = Path(config.loras_roots[0]) / "by-id.webp"
    await scanner.add_recipe(
        {
            "id": "by-id-checkpoint",
            "file_path": str(image_path),
            "title": "ById",
            "modified": 0.0,
            "created_date": 0.0,
            "loras": [],
            "checkpoint": ("by-id.safetensors",),
        }
    )

    recipe = await scanner.get_recipe_by_id("by-id-checkpoint")

    assert recipe["checkpoint"]["name"] == "by-id.safetensors"
    assert recipe["checkpoint"]["file_name"] == "by-id"


@pytest.mark.asyncio
async def test_get_recipe_by_id_detects_workflow_for_legacy_recipes(
    recipe_scanner, monkeypatch
):
    scanner, _ = recipe_scanner
    await scanner.add_recipe(
        {
            "id": "legacy-no-flag",
            "file_path": "/models/recipes/legacy.webp",
            "title": "Legacy",
            "modified": 0.0,
            "created_date": 0.0,
            "loras": [],
        }
    )

    monkeypatch.setattr(
        RecipeScanner, "_detect_has_workflow", lambda self, path: True
    )

    recipe = await scanner.get_recipe_by_id("legacy-no-flag")

    assert recipe is not None
    assert recipe["has_workflow"] is True


@pytest.mark.asyncio
async def test_get_recipe_by_id_keeps_existing_has_workflow_flag(recipe_scanner):
    scanner, _ = recipe_scanner
    await scanner.add_recipe(
        {
            "id": "flagged",
            "file_path": "/models/recipes/flagged.webp",
            "title": "Flagged",
            "modified": 0.0,
            "created_date": 0.0,
            "loras": [],
            "has_workflow": False,
        }
    )

    recipe = await scanner.get_recipe_by_id("flagged")

    assert recipe is not None
    assert recipe["has_workflow"] is False


@pytest.mark.asyncio
async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner):
    scanner, _ = recipe_scanner
    recipes_dir = Path(scanner.recipes_dir)
    recipe_id = "hydrate-me"
    recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
    recipe_json_path.write_text(
        json.dumps(
            {
                "id": recipe_id,
                "file_path": "/tmp/hydrate-me.png",
                "title": "Hydrated Recipe",
                "source_path": "https://example.com/source",
                "gen_params": {
                    "prompt": "prompt from json",
                    "negative_prompt": "negative from json",
                },
                "loras": [],
            }
        ),
        encoding="utf-8",
    )

    scanner._cache.raw_data = [
        {
            "id": recipe_id,
            "file_path": "/tmp/hydrate-me.png",
            "title": "Cached Recipe",
            "folder": "",
            "modified": 0.0,
            "created_date": 0.0,
            "loras": [],
            "gen_params": {},
        }
    ]

    recipe = await scanner.get_recipe_by_id(recipe_id)

    assert recipe is not None
    assert recipe["title"] == "Hydrated Recipe"
    assert recipe["source_path"] == "https://example.com/source"
    assert recipe["gen_params"]["prompt"] == "prompt from json"


@pytest.mark.asyncio
async def test_get_recipe_by_id_normalizes_gen_params_aliases_without_dropping_metadata(
    recipe_scanner,
):
    scanner, _ = recipe_scanner
    recipes_dir = Path(scanner.recipes_dir)
    recipe_id = "dirty-json-gen-params"
    recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
    recipe_json_path.write_text(
        json.dumps(
            {
                "id": recipe_id,
                "file_path": "/tmp/dirty-json-gen-params.png",
                "title": "Dirty Recipe",
                "gen_params": {
                    "Prompt": "prompt from json",
                    "negativePrompt": "negative from json",
                    "cfgScale": 7,
                    "raw_metadata": {"prompt": "nested"},
                    "Version": "ComfyUI",
                    "RNG": "cpu",
                },
                "loras": [],
            }
        ),
        encoding="utf-8",
    )

    scanner._cache.raw_data = [
        {
            "id": recipe_id,
            "file_path": "/tmp/dirty-json-gen-params.png",
            "title": "Cached Recipe",
            "folder": "",
            "modified": 0.0,
            "created_date": 0.0,
            "loras": [],
            "gen_params": {"prompt": "cached prompt", "raw_metadata": {"bad": True}},
        }
    ]

    recipe = await scanner.get_recipe_by_id(recipe_id)

    assert recipe is not None
    assert recipe["gen_params"]["Prompt"] == "prompt from json"
    assert recipe["gen_params"]["negativePrompt"] == "negative from json"
    assert recipe["gen_params"]["cfgScale"] == 7
    assert recipe["gen_params"]["raw_metadata"] == {"prompt": "nested"}
    assert recipe["gen_params"]["Version"] == "ComfyUI"
    assert recipe["gen_params"]["RNG"] == "cpu"
    assert recipe["gen_params"]["prompt"] == "prompt from json"
    assert recipe["gen_params"]["negative_prompt"] == "negative from json"
    assert recipe["gen_params"]["cfg_scale"] == 7


@pytest.mark.asyncio
async def test_get_recipe_by_id_prefers_json_file_path(recipe_scanner):
    scanner, _ = recipe_scanner
    recipes_dir = Path(scanner.recipes_dir)
    recipe_id = "move-me"
    recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
    recipe_json_path.write_text(
        json.dumps(
            {
                "id": recipe_id,
                "file_path": "/tmp/new-location.png",
                "title": "Moved Recipe",
                "source_path": "https://example.com/moved",
                "gen_params": {},
                "loras": [],
            }
        ),
        encoding="utf-8",
    )

    scanner._cache.raw_data = [
        {
            "id": recipe_id,
            "file_path": "/tmp/old-location.png",
            "title": "Cached Title",
            "folder": "",
            "modified": 0.0,
            "created_date": 0.0,
            "loras": [],
            "gen_params": {},
        }
    ]

    recipe = await scanner.get_recipe_by_id(recipe_id)

    assert recipe is not None
    assert recipe["file_path"] == "/tmp/new-location.png"
    assert recipe["title"] == "Moved Recipe"
    assert recipe["source_path"] == "https://example.com/moved"


@pytest.mark.asyncio
async def test_get_recipe_by_id_drops_deleted_optional_json_fields(recipe_scanner):
    scanner, _ = recipe_scanner
    recipes_dir = Path(scanner.recipes_dir)
    recipe_id = "drop-optional-fields"
    recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
    recipe_json_path.write_text(
        json.dumps(
            {
                "id": recipe_id,
                "file_path": "/tmp/drop-optional-fields.png",
                "title": "Trimmed Recipe",
            }
        ),
        encoding="utf-8",
    )

    scanner._cache.raw_data = [
        {
            "id": recipe_id,
            "file_path": "/tmp/drop-optional-fields.png",
            "title": "Cached Recipe",
            "folder": "",
            "modified": 0.0,
            "created_date": 0.0,
            "source_path": "https://example.com/stale-source",
            "checkpoint": {"name": "stale-checkpoint.safetensors"},
            "loras": [{"modelName": "stale-lora"}],
            "gen_params": {"prompt": "stale prompt"},
        }
    ]

    recipe = await scanner.get_recipe_by_id(recipe_id)

    assert recipe is not None
    assert recipe["title"] == "Trimmed Recipe"
    assert "source_path" not in recipe
    assert "checkpoint" not in recipe
    assert "gen_params" not in recipe
    assert "loras" not in recipe


@pytest.mark.asyncio
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
    scanner, _ = recipe_scanner
    image_path = Path(config.loras_roots[0]) / "checkpoint-filter.webp"
    await scanner.add_recipe(
        {
            "id": "checkpoint-match",
            "file_path": str(image_path),
            "title": "Checkpoint Match",
            "modified": 0.0,
            "created_date": 0.0,
            "loras": [],
            "checkpoint": {
                "name": "flux-base.safetensors",
                "hash": "ABC123",
            },
        }
    )
    await scanner.add_recipe(
        {
            "id": "checkpoint-miss",
            "file_path": str(Path(config.loras_roots[0]) / "checkpoint-miss.webp"),
            "title": "Checkpoint Miss",
            "modified": 1.0,
            "created_date": 1.0,
            "loras": [],
            "checkpoint": {
                "name": "other.safetensors",
                "hash": "zzz999",
            },
        }
    )
    await asyncio.sleep(0)
    await _wait_for_resort(scanner)

    result = await scanner.get_paginated_data(
        page=1,
        page_size=10,
        checkpoint_hash="abc123",
    )

    assert [item["id"] for item in result["items"]] == ["checkpoint-match"]


@pytest.mark.asyncio
async def test_get_paginated_data_normalizes_gen_params_aliases_without_dropping_metadata(
    recipe_scanner,
):
    scanner, _ = recipe_scanner
    await scanner.add_recipe(
        {
            "id": "dirty-listing",
            "file_path": str(Path(config.loras_roots[0]) / "dirty-listing.webp"),
            "title": "Dirty Listing",
            "modified": 0.0,
            "created_date": 0.0,
            "loras": [],
            "gen_params": {
                "Prompt": "a beautiful forest landscape",
                "cfgScale": 7,
                "Version": "ComfyUI",
                "raw_metadata": {"bad": True},
            },
        }
    )
    await asyncio.sleep(0)
    await _wait_for_resort(scanner)

    result = await scanner.get_paginated_data(page=1, page_size=10)
    item = next(entry for entry in result["items"] if entry["id"] == "dirty-listing")

    assert item["gen_params"]["Prompt"] == "a beautiful forest landscape"
    assert item["gen_params"]["cfgScale"] == 7
    assert item["gen_params"]["Version"] == "ComfyUI"
    assert item["gen_params"]["raw_metadata"] == {"bad": True}
    assert item["gen_params"]["prompt"] == "a beautiful forest landscape"
    assert item["gen_params"]["cfg_scale"] == 7


@pytest.mark.asyncio
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner):
    scanner, _ = recipe_scanner
    image_path = Path(config.loras_roots[0]) / "checkpoint-linked.webp"
    await scanner.add_recipe(
        {
            "id": "checkpoint-linked",
            "file_path": str(image_path),
            "title": "Checkpoint Linked",
            "modified": 0.0,
            "created_date": 0.0,
            "loras": [],
            "checkpoint": {
                "name": "flux-base.safetensors",
                "hash": "ABC123",
            },
        }
    )

    recipes = await scanner.get_recipes_for_checkpoint("abc123")

    assert len(recipes) == 1
    assert recipes[0]["id"] == "checkpoint-linked"
    assert recipes[0]["checkpoint"]["hash"] == "ABC123"


def test_enrich_uses_version_index_when_hash_missing(recipe_scanner):
    scanner, stub = recipe_scanner
    version_id = 77
    file_path = str(Path(config.loras_roots[0]) / "loras" / "version-entry.safetensors")
    registered = {
        "sha256": "deadbeef",
        "file_path": file_path,
        "preview_url": "preview-from-cache.png",
        "civitai": {"id": version_id},
    }
    stub.register_model("version-entry", registered)

    lora = {"hash": "", "file_name": "", "modelVersionId": version_id, "strength": 0.5}

    enriched = scanner._enrich_lora_entry(dict(lora))

    assert enriched["inLibrary"] is True
    assert enriched["hash"] == registered["sha256"]
    assert enriched["localPath"] == file_path
    assert enriched["file_name"] == Path(file_path).stem
    assert enriched["preview_url"] == registered["preview_url"]


def test_enrich_formats_absolute_preview_paths(recipe_scanner, tmp_path):
    scanner, stub = recipe_scanner
    version_id = 88
    preview_path = tmp_path / "loras" / "version-entry.preview.jpeg"
    preview_path.parent.mkdir(parents=True, exist_ok=True)
    preview_path.write_text("preview")
    model_path = tmp_path / "loras" / "version-entry.safetensors"
    model_path.write_text("weights")

    stub.register_model(
        "absolute-preview",
        {
            "sha256": "feedface",
            "file_path": str(model_path),
            "preview_url": str(preview_path),
            "civitai": {"id": version_id},
        },
    )

    lora = {"hash": "", "file_name": "", "modelVersionId": version_id, "strength": 0.5}

    enriched = scanner._enrich_lora_entry(dict(lora))

    assert enriched["preview_url"] == config.get_preview_static_url(str(preview_path))


@pytest.mark.asyncio
async def test_initialize_waits_for_lora_scanner(monkeypatch):
    ready_flag = asyncio.Event()
    call_count = 0

    class StubLoraScanner:
        def __init__(self):
            self._cache = None
            self._is_initializing = True

        async def initialize_in_background(self):
            nonlocal call_count
            call_count += 1
            await asyncio.sleep(0)
            self._cache = SimpleNamespace(raw_data=[])
            self._is_initializing = False
            ready_flag.set()

    lora_scanner = StubLoraScanner()
    scanner = RecipeScanner(lora_scanner=lora_scanner)  # pyright: ignore[reportArgumentType]

    await scanner.initialize_in_background()

    assert ready_flag.is_set()
    assert call_count == 1
    assert scanner._cache is not None


@pytest.mark.asyncio
async def test_invalid_model_version_marked_deleted_and_not_retried(
    monkeypatch, recipe_scanner
):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    recipe: Dict[str, Any] = {
        "id": "invalid-version",
        "file_path": str(recipes_dir / "invalid-version.webp"),
        "title": "Invalid",
        "modified": 0.0,
        "created_date": 0.0,
        "loras": [{"modelVersionId": 999, "file_name": "", "hash": ""}],
    }
    await scanner.add_recipe(dict(recipe))

    call_count = 0

    async def fake_get_hash(model_version_id):
        nonlocal call_count
        call_count += 1
        return None

    monkeypatch.setattr(scanner, "_get_hash_from_civitai", fake_get_hash)

    metadata_updated = await scanner._update_lora_information(recipe)

    assert metadata_updated is True
    assert recipe["loras"][0]["isDeleted"] is True
    assert call_count == 1

    # Subsequent calls should skip remote lookup once marked deleted
    metadata_updated_again = await scanner._update_lora_information(recipe)
    assert metadata_updated_again is False
    assert call_count == 1


@pytest.mark.asyncio
async def test_load_recipe_persists_deleted_flag_on_invalid_version(
    monkeypatch, recipe_scanner, tmp_path
):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    recipe_id = "persist-invalid"
    recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
    recipe_data = {
        "id": recipe_id,
        "file_path": str(recipes_dir / f"{recipe_id}.webp"),
        "title": "Invalid",
        "modified": 0.0,
        "created_date": 0.0,
        "loras": [{"modelVersionId": 1234, "file_name": "", "hash": ""}],
    }
    recipe_path.write_text(json.dumps(recipe_data))

    async def fake_get_hash(model_version_id):
        return None

    monkeypatch.setattr(scanner, "_get_hash_from_civitai", fake_get_hash)

    loaded = await scanner._load_recipe_file(str(recipe_path))

    assert loaded["loras"][0]["isDeleted"] is True

    persisted = json.loads(recipe_path.read_text())
    assert persisted["loras"][0]["isDeleted"] is True


@pytest.mark.asyncio
async def test_update_lora_filename_by_hash_updates_affected_recipes(
    tmp_path: Path, recipe_scanner
):
    scanner, _ = recipe_scanner
    recipes_dir = Path(config.loras_roots[0]) / "recipes"
    recipes_dir.mkdir(parents=True, exist_ok=True)

    # Recipe 1: Contains the LoRA with hash "hash1"
    recipe1_id = "recipe1"
    recipe1_path = recipes_dir / f"{recipe1_id}.recipe.json"
    recipe1_data = {
        "id": recipe1_id,
        "file_path": str(tmp_path / "img1.png"),
        "title": "Recipe 1",
        "modified": 0.0,
        "created_date": 0.0,
        "loras": [
            {"file_name": "old_name", "hash": "hash1"},
            {"file_name": "other_lora", "hash": "hash2"},
        ],
    }
    recipe1_path.write_text(json.dumps(recipe1_data))
    await scanner.add_recipe(dict(recipe1_data))

    # Recipe 2: Does NOT contain the LoRA
    recipe2_id = "recipe2"
    recipe2_path = recipes_dir / f"{recipe2_id}.recipe.json"
    recipe2_data = {
        "id": recipe2_id,
        "file_path": str(tmp_path / "img2.png"),
        "title": "Recipe 2",
        "modified": 0.0,
        "created_date": 0.0,
        "loras": [{"file_name": "other_lora", "hash": "hash2"}],
    }
    recipe2_path.write_text(json.dumps(recipe2_data))
    await scanner.add_recipe(dict(recipe2_data))

    # Update LoRA name for "hash1" (using different case to test normalization)
    new_name = "new_name"
    file_count, cache_count = await scanner.update_lora_filename_by_hash(
        "HASH1", new_name
    )

    assert file_count == 1
    assert cache_count == 1

    # Check file on disk
    persisted1 = json.loads(recipe1_path.read_text())
    assert persisted1["loras"][0]["file_name"] == new_name
    assert persisted1["loras"][1]["file_name"] == "other_lora"

    # Verify Recipe 2 unchanged
    persisted2 = json.loads(recipe2_path.read_text())
    assert persisted2["loras"][0]["file_name"] == "other_lora"

    cache = await scanner.get_cached_data()
    cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id)
    assert cached1["loras"][0]["file_name"] == new_name


@pytest.mark.asyncio
async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
    scanner, _ = recipe_scanner

    # Add a normal recipe
    await scanner.add_recipe(
        {
            "id": "regular",
            "file_path": "path/regular.png",
            "title": "Regular Recipe",
            "modified": 1.0,
            "created_date": 1.0,
            "loras": [],
        }
    )

    # Add a favorite recipe
    await scanner.add_recipe(
        {
            "id": "favorite",
            "file_path": "path/favorite.png",
            "title": "Favorite Recipe",
            "modified": 2.0,
            "created_date": 2.0,
            "loras": [],
            "favorite": True,
        }
    )

    # Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe)
    await asyncio.sleep(0)
    await _wait_for_resort(scanner)

    # Test without filter (should return both)
    result_all = await scanner.get_paginated_data(page=1, page_size=10)
    assert len(result_all["items"]) == 2

    # Test with favorite filter
    result_fav = await scanner.get_paginated_data(
        page=1, page_size=10, filters={"favorite": True}
    )
    assert len(result_fav["items"]) == 1
    assert result_fav["items"][0]["id"] == "favorite"

    # Test with favorite filter set to False (should return both or at least not filter if it's the default)
    # Actually our implementation checks if 'favorite' in filters and filters['favorite']
    result_fav_false = await scanner.get_paginated_data(
        page=1, page_size=10, filters={"favorite": False}
    )
    assert len(result_fav_false["items"]) == 2


@pytest.mark.asyncio
async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
    scanner, _ = recipe_scanner

    # Add a recipe with a specific prompt
    await scanner.add_recipe(
        {
            "id": "prompt-recipe",
            "file_path": "path/prompt.png",
            "title": "Prompt Recipe",
            "modified": 1.0,
            "created_date": 1.0,
            "loras": [],
            "gen_params": {"prompt": "a beautiful forest landscape"},
        }
    )

    # Add a recipe with a specific negative prompt
    await scanner.add_recipe(
        {
            "id": "neg-prompt-recipe",
            "file_path": "path/neg.png",
            "title": "Negative Prompt Recipe",
            "modified": 2.0,
            "created_date": 2.0,
            "loras": [],
            "gen_params": {"negative_prompt": "ugly, blurry mountains"},
        }
    )

    await asyncio.sleep(0)
    await _wait_for_resort(scanner)

    # Test search in prompt
    result_prompt = await scanner.get_paginated_data(
        page=1, page_size=10, search="forest", search_options={"prompt": True}
    )
    assert len(result_prompt["items"]) == 1
    assert result_prompt["items"][0]["id"] == "prompt-recipe"

    # Test search in negative prompt
    result_neg = await scanner.get_paginated_data(
        page=1, page_size=10, search="mountains", search_options={"prompt": True}
    )
    assert len(result_neg["items"]) == 1
    assert result_neg["items"][0]["id"] == "neg-prompt-recipe"

    # Test search disabled (should not find by prompt)
    result_disabled = await scanner.get_paginated_data(
        page=1, page_size=10, search="forest", search_options={"prompt": False}
    )
    assert len(result_disabled["items"]) == 0


@pytest.mark.asyncio
async def test_get_paginated_data_sorting(recipe_scanner):
    scanner, _ = recipe_scanner

    # Add test recipes
    # Recipe A: Name "Alpha", Date 10, LoRAs 2
    await scanner.add_recipe(
        {
            "id": "A",
            "title": "Alpha",
            "created_date": 10.0,
            "loras": [{}, {}],
            "file_path": "a.png",
        }
    )
    # Recipe B: Name "Beta", Date 20, LoRAs 1
    await scanner.add_recipe(
        {
            "id": "B",
            "title": "Beta",
            "created_date": 20.0,
            "loras": [{}],
            "file_path": "b.png",
        }
    )
    # Recipe C: Name "Gamma", Date 5, LoRAs 3
    await scanner.add_recipe(
        {
            "id": "C",
            "title": "Gamma",
            "created_date": 5.0,
            "loras": [{}, {}, {}],
            "file_path": "c.png",
        }
    )

    await asyncio.sleep(0)
    await _wait_for_resort(scanner)

    # Test Name DESC: Gamma, Beta, Alpha
    res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="name:desc")
    assert [i["id"] for i in res["items"]] == ["C", "B", "A"]

    # Test LoRA Count DESC: Gamma (3), Alpha (2), Beta (1)
    res = await scanner.get_paginated_data(
        page=1, page_size=10, sort_by="loras_count:desc"
    )
    assert [i["id"] for i in res["items"]] == ["C", "A", "B"]

    # Test LoRA Count ASC: Beta (1), Alpha (2), Gamma (3)
    res = await scanner.get_paginated_data(
        page=1, page_size=10, sort_by="loras_count:asc"
    )
    assert [i["id"] for i in res["items"]] == ["B", "A", "C"]

    # Test Date ASC: Gamma (5), Alpha (10), Beta (20)
    res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="date:asc")
    assert [i["id"] for i in res["items"]] == ["C", "A", "B"]


@pytest.mark.asyncio
async def test_get_paginated_data_random_sort(recipe_scanner):
    scanner, _ = recipe_scanner

    # Add test recipes
    for rid, title in [("A", "Alpha"), ("B", "Beta"), ("C", "Gamma")]:
        await scanner.add_recipe(
            {
                "id": rid,
                "title": title,
                "created_date": 10.0,
                "loras": [{}],
                "file_path": f"{rid.lower()}.png",
            }
        )

    await asyncio.sleep(0)
    await _wait_for_resort(scanner)

    # Same seed -> same order (deterministic, stable pagination)
    res1 = await scanner.get_paginated_data(
        page=1, page_size=10, sort_by="random:seed123"
    )
    res2 = await scanner.get_paginated_data(
        page=1, page_size=10, sort_by="random:seed123"
    )
    ids1 = [i["id"] for i in res1["items"]]
    ids2 = [i["id"] for i in res2["items"]]
    assert ids1 == ids2
    assert sorted(ids1) == ["A", "B", "C"]

    # Plain "random" (no seed) also returns the full set
    res3 = await scanner.get_paginated_data(page=1, page_size=10, sort_by="random")
    assert sorted(i["id"] for i in res3["items"]) == ["A", "B", "C"]

    # Stable pagination: page1 + page2 with the same seed concatenate to the
    # full seeded order, with no duplicates across pages
    p1 = await scanner.get_paginated_data(
        page=1, page_size=2, sort_by="random:seed123"
    )
    p2 = await scanner.get_paginated_data(
        page=2, page_size=2, sort_by="random:seed123"
    )
    combined = [i["id"] for i in p1["items"]] + [i["id"] for i in p2["items"]]
    assert combined == ids1
    assert len(set(combined)) == 3


@pytest.mark.asyncio
async def test_get_paginated_data_opened_sort(recipe_scanner, monkeypatch):
    scanner, _ = recipe_scanner

    for rid, title in [("A", "Alpha"), ("B", "Beta"), ("C", "Gamma")]:
        await scanner.add_recipe(
            {
                "id": rid,
                "title": title,
                "created_date": 10.0,
                "loras": [{}],
                "file_path": f"{rid.lower()}.png",
            }
        )

    await asyncio.sleep(0)
    await _wait_for_resort(scanner)

    class _FakeStats:
        def get_opened_map(self):
            return {"B": 300.0, "C": 200.0}

    monkeypatch.setattr(
        "py.services.recipe_scanner.RecipeOpenStats", lambda: _FakeStats()
    )

    # Never-opened A is hidden from the view; B (300) > C (200)
    res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:desc")
    assert [i["id"] for i in res["items"]] == ["B", "C"]
    assert res["total"] == 2

    # ASC: C (200) < B (300)
    res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:asc")
    assert [i["id"] for i in res["items"]] == ["C", "B"]

    # Plain "opened" (no direction) behaves like desc by default
    res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened")
    assert [i["id"] for i in res["items"]] == ["B", "C"]

    # When nothing was opened the view is empty (not a fallback reorder)
    class _EmptyStats:
        def get_opened_map(self):
            return {}

    monkeypatch.setattr(
        "py.services.recipe_scanner.RecipeOpenStats", lambda: _EmptyStats()
    )
    res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:desc")
    assert res["items"] == []
    assert res["total"] == 0


async def test_build_image_id_map_filters_correctly(recipe_scanner):
    """Only recipes with valid CivitAI source_path appear in image_id_map.

    Recipes imported from local files or with empty/missing source_path
    must be naturally excluded.
    """
    scanner, _ = recipe_scanner
    from py.services.recipe_cache import RecipeCache

    scanner._cache = RecipeCache(
        raw_data=[
            {"id": "r1", "source_path": "https://civitai.com/images/12345"},
            {"id": "r2", "source_path": "https://civitai.com/images/67890"},
            {"id": "r3", "source_path": "/home/user/local_image.png"},
            {"id": "r4", "source_path": ""},
            {"id": "r5"},
        ],
        sorted_by_name=[],
        sorted_by_date=[],
    )

    result = scanner._build_image_id_map()

    assert result == {
        "12345": "r1",
        "67890": "r2",
    }
    # r3 = local file path, r4 = empty string, r5 = no key → all excluded
    for rid in ("r3", "r4", "r5"):
        assert rid not in result.values()


async def test_add_recipe_updates_image_id_map(recipe_scanner):
    """Adding a recipe with a CivitAI URL must update image_id_map.

    A recipe with a local file path must NOT produce an entry.
    """
    scanner, _ = recipe_scanner

    await scanner.add_recipe({
        "id": "civitai-recipe",
        "title": "CivitAI",
        "source_path": "https://civitai.com/images/55555",
    })

    cache = await scanner.get_cached_data()
    assert cache.image_id_map.get("55555") == "civitai-recipe"

    await scanner.add_recipe({
        "id": "local-recipe",
        "title": "Local",
        "source_path": "/path/to/local.png",
    })

    assert "local-recipe" not in cache.image_id_map.values()


async def test_remove_recipe_clears_image_id_map(recipe_scanner):
    """Removing a recipe that has a CivitAI image_id must clean up the map."""
    scanner, _ = recipe_scanner

    await scanner.add_recipe({
        "id": "recipe-a",
        "title": "A",
        "source_path": "https://civitai.com/images/111",
    })
    await scanner.add_recipe({
        "id": "recipe-b",
        "title": "B",
        "source_path": "https://civitai.com/images/222",
    })

    cache = await scanner.get_cached_data()
    assert "111" in cache.image_id_map
    assert cache.image_id_map["222"] == "recipe-b"

    await scanner.remove_recipe("recipe-a")

    assert "111" not in cache.image_id_map
    assert cache.image_id_map["222"] == "recipe-b"


# ---------------------------------------------------------------------------
# cache_version — ModelScanner write-path bump coverage (plan todo 1)
# ---------------------------------------------------------------------------


class DummyScanner(ModelScanner):
    """Minimal ModelScanner subclass exercising the base-class write paths."""

    def __init__(self, root: str):
        self._root = root
        super().__init__(
            model_type="dummy",
            model_class=BaseModelMetadata,
            file_extensions={".txt"},
            hash_index=ModelHashIndex(),
        )

    def get_model_roots(self) -> list[str]:
        return [self._root]

    async def _process_model_file(
        self,
        file_path: str,
        root_path: str,
        *,
        hash_index: ModelHashIndex | None = None,
        excluded_models: list[str] | None = None,
    ) -> Dict[str, Any] | None:
        hash_index = hash_index or self._hash_index
        excluded_models = excluded_models if excluded_models is not None else self._excluded_models
        name = os.path.splitext(os.path.basename(file_path))[0]
        if name.startswith("skip"):
            excluded_models.append(file_path.replace(os.sep, "/"))
            return None
        return {
            "file_path": file_path.replace(os.sep, "/"),
            "folder": os.path.dirname(os.path.relpath(file_path, root_path)).replace(os.path.sep, "/"),
            "sha256": f"hash-{name}",
            "tags": [],
            "model_name": name,
            "file_name": name,
            "size": 1,
            "modified": 1.0,
        }


class DummyScannerB(DummyScanner):
    """A second ModelScanner subclass so per-class versions are independent."""


class DummyScannerC(DummyScanner):
    """A third ModelScanner subclass (embedding stand-in for the misc route test)."""


async def _empty_metadata_loader(path: str) -> Dict[str, Any]:
    return {}


class DummyMetadataManagerForLifecycle:
    async def load_metadata_payload(self, file_path: str) -> Dict[str, Any]:
        return {}

    async def save_metadata(self, file_path: str, metadata: Dict[str, Any]) -> None:
        return None


def _make_scanner(raw_data: list[Dict[str, Any]], root: str) -> DummyScanner:
    scanner = DummyScanner(root)
    scanner._cache = ModelCache(raw_data=[dict(item) for item in raw_data], folders=[])
    return scanner


def _normalize(root_path: str) -> str:
    return root_path.replace(os.sep, "/")


async def test_cache_version_starts_at_zero(tmp_path: Path):
    scanner = DummyScanner(str(tmp_path))
    assert scanner.cache_version == 0


async def test_scan_apply_bumps_cache_version(tmp_path: Path):
    scanner = _make_scanner([], str(tmp_path))
    result = CacheBuildResult(
        raw_data=[{"file_path": "a.txt", "folder": "", "sha256": "abc", "tags": []}],
        hash_index=ModelHashIndex(),
        tags_count={},
        excluded_models=[],
    )
    assert scanner.cache_version == 0
    await scanner._apply_scan_result(result)
    assert scanner.cache_version == 1
    assert scanner._cache.raw_data == result.raw_data


async def test_add_model_to_cache_bumps_cache_version(tmp_path: Path):
    scanner = _make_scanner([], str(tmp_path))
    assert scanner.cache_version == 0
    ok = await scanner.add_model_to_cache(
        {"file_path": "x.txt", "folder": "", "sha256": "abc", "tags": []}
    )
    assert ok is True
    assert scanner.cache_version == 1
    assert len(scanner._cache.raw_data) == 1


async def test_update_single_model_cache_bumps_cache_version(tmp_path: Path):
    scanner = _make_scanner(
        [{"file_path": "old.txt", "folder": "", "sha256": "abc", "tags": [], "model_name": "m", "file_name": "old"}],
        str(tmp_path),
    )
    await scanner._cache.resort()
    assert scanner.cache_version == 0
    result = await scanner.update_single_model_cache(
        "old.txt",
        "new.txt",
        {"sha256": "def", "tags": [], "model_name": "new", "file_name": "new"},
    )
    assert result is not None
    assert scanner.cache_version == 1
    assert [item["file_path"] for item in scanner._cache.raw_data] == ["new.txt"]


async def test_sync_cache_from_metadata_sha256_change_bumps_cache_version(tmp_path: Path):
    scanner = _make_scanner(
        [{"file_path": "m.txt", "folder": "", "sha256": "oldsha", "tags": [], "model_name": "m", "file_name": "m"}],
        str(tmp_path),
    )
    await scanner._cache.resort()
    assert scanner.cache_version == 0
    changed = await scanner._sync_cache_from_metadata_impl(
        "m.txt",
        {"sha256": "newsha", "tags": [], "model_name": "m", "file_name": "m", "size": 1, "modified": 1.0},
    )
    assert changed is True
    assert scanner.cache_version == 1
    assert scanner._cache.raw_data[0]["sha256"] == "newsha"


async def test_update_autov3_for_model_bumps_cache_version(tmp_path: Path):
    scanner = _make_scanner(
        [{"file_path": "m.txt", "folder": "", "sha256": "abc", "tags": [], "model_name": "m", "file_name": "m", "autov3": ""}],
        str(tmp_path),
    )
    assert scanner.cache_version == 0
    ok = await scanner.update_autov3_for_model("dummy", "m.txt", "AAA12BBB34CD")
    assert ok is True
    assert scanner.cache_version == 1
    assert scanner._cache.raw_data[0]["autov3"] == "aaa12bbb34cd"


async def test_batch_remove_bumps_cache_version(tmp_path: Path):
    scanner = _make_scanner(
        [{"file_path": "gone.txt", "folder": "", "sha256": "abc", "tags": [], "model_name": "gone", "file_name": "gone"}],
        str(tmp_path),
    )
    assert scanner.cache_version == 0
    updated = await scanner._batch_update_cache_for_deleted_models(["gone.txt"])
    assert updated is True
    assert scanner.cache_version == 1
    assert scanner._cache.raw_data == []


async def test_reconcile_cache_append_only_bumps_cache_version(tmp_path: Path):
    root = tmp_path / "models"
    root.mkdir()
    (root / "new.txt").write_text("data", encoding="utf-8")
    scanner = _make_scanner([], str(root))
    assert scanner.cache_version == 0
    await scanner._reconcile_cache()
    assert scanner.cache_version == 1
    assert len(scanner._cache.raw_data) == 1


async def test_reconcile_cache_bumps_unconditionally(tmp_path: Path):
    root = tmp_path / "models"
    root.mkdir()
    scanner = _make_scanner([], str(root))
    assert scanner.cache_version == 0
    await scanner._reconcile_cache()
    assert scanner.cache_version == 1


async def test_get_cached_data_read_does_not_bump_cache_version(tmp_path: Path):
    scanner = _make_scanner([], str(tmp_path))
    assert scanner.cache_version == 0
    cache = await scanner.get_cached_data()
    assert cache is scanner._cache
    assert scanner.cache_version == 0
    _ = scanner.cache_version
    assert scanner.cache_version == 0


async def test_scanner_versions_are_independent(tmp_path: Path):
    root = tmp_path / "models"
    root.mkdir()
    lora = DummyScanner(str(root))
    checkpoint = DummyScannerB(str(root))
    assert lora.cache_version == 0
    assert checkpoint.cache_version == 0
    lora.bump_cache_version()
    assert lora.cache_version == 1
    assert checkpoint.cache_version == 0


async def test_on_library_changed_bumps_cache_version(tmp_path: Path, monkeypatch):
    scanner = DummyScanner(str(tmp_path))
    assert scanner.cache_version == 0

    async def _noop_initialize() -> None:
        pass

    monkeypatch.setattr(scanner, "initialize_in_background", _noop_initialize)
    scanner.on_library_changed()
    assert scanner.cache_version == 1


async def test_checkpoint_lazy_hash_bumps_cache_version(tmp_path: Path, monkeypatch):
    from py.services.checkpoint_scanner import CheckpointScanner

    checkpoints_root = tmp_path / "checkpoints"
    checkpoints_root.mkdir()
    checkpoint_file = checkpoints_root / "test_model.safetensors"
    checkpoint_file.write_text("fake content", encoding="utf-8")

    normalized_root = _normalize(str(checkpoints_root))
    normalized_file = _normalize(str(checkpoint_file))

    monkeypatch.setattr(
        model_scanner_module.config, "base_models_roots", [normalized_root], raising=False
    )
    monkeypatch.setattr(
        model_scanner_module.config, "checkpoints_roots", [normalized_root], raising=False
    )

    scanner = CheckpointScanner()
    scanner._cache = ModelCache(
        raw_data=[
            {
                "file_path": normalized_file,
                "folder": "",
                "sha256": "",
                "hash_status": "pending",
                "tags": [],
                "model_name": "test_model",
                "file_name": "test_model",
            }
        ],
        folders=[],
    )
    assert scanner.cache_version == 0
    hash_result = await scanner.calculate_hash_for_model(normalized_file)
    assert hash_result is not None
    assert scanner.cache_version == 1
    assert scanner._cache.raw_data[0]["sha256"] == hash_result.lower()


async def test_lifecycle_delete_model_bumps_cache_version(tmp_path: Path):
    from py.services.model_lifecycle_service import ModelLifecycleService

    root = tmp_path / "loras"
    root.mkdir()
    model = root / "model.safetensors"
    model.write_bytes(b"data")

    scanner = _make_scanner(
        [{"file_path": str(model), "folder": "", "sha256": "abc", "tags": [], "model_name": "m", "file_name": "m"}],
        str(root),
    )
    service = ModelLifecycleService(
        scanner=scanner,
        metadata_manager=DummyMetadataManagerForLifecycle(),
        metadata_loader=_empty_metadata_loader,
    )
    assert scanner.cache_version == 0
    result = await service.delete_model(str(model))
    assert result["success"] is True
    assert scanner.cache_version == 1
    assert scanner._cache.raw_data == []


async def test_lifecycle_exclude_model_bumps_cache_version(tmp_path: Path):
    from py.services.model_lifecycle_service import ModelLifecycleService

    root = tmp_path / "loras"
    root.mkdir()
    model = root / "model.safetensors"
    model.write_bytes(b"data")

    scanner = _make_scanner(
        [{"file_path": str(model), "folder": "", "sha256": "abc", "tags": [], "model_name": "m", "file_name": "m"}],
        str(root),
    )
    service = ModelLifecycleService(
        scanner=scanner,
        metadata_manager=DummyMetadataManagerForLifecycle(),
        metadata_loader=_empty_metadata_loader,
    )
    assert scanner.cache_version == 0
    result = await service.exclude_model(str(model))
    assert result["success"] is True
    assert scanner.cache_version == 1
    assert scanner._cache.raw_data == []


async def test_misc_delete_model_version_bumps_cache_version(tmp_path: Path):
    from aiohttp.test_utils import make_mocked_request

    from py.routes.handlers.misc_handlers import ModelLibraryHandler, ServiceRegistryAdapter

    root = tmp_path / "loras"
    root.mkdir()
    model = root / "model.safetensors"
    model.write_bytes(b"data")

    lora_scanner = _make_scanner(
        [
            {
                "file_path": str(model),
                "folder": "",
                "sha256": "abc",
                "tags": [],
                "model_name": "m",
                "file_name": "m",
                "civitai": {"id": 42, "modelId": 7, "name": "m"},
            }
        ],
        str(root),
    )
    lora_scanner._cache.rebuild_version_index()
    # Use distinct scanner classes: ModelScanner is a per-class singleton, so
    # re-instantiating DummyScanner would return the same instance and clobber
    # the lora cache set above.
    checkpoint_scanner = DummyScannerB(str(root))
    checkpoint_scanner._cache = ModelCache(raw_data=[], folders=[])
    embedding_scanner = DummyScannerC(str(root))
    embedding_scanner._cache = ModelCache(raw_data=[], folders=[])

    deleted: list[tuple[str, int]] = []

    async def history_factory():
        class FakeHistory:
            async def mark_as_deleted(self, model_type: str, model_version_id: int) -> None:
                deleted.append((model_type, model_version_id))

        return FakeHistory()

    async def lora_factory():
        return lora_scanner

    async def checkpoint_factory():
        return checkpoint_scanner

    async def embedding_factory():
        return embedding_scanner

    async def _noop_metadata_provider() -> Any:
        return None

    handler = ModelLibraryHandler(
        ServiceRegistryAdapter(
            get_lora_scanner=lora_factory,
            get_checkpoint_scanner=checkpoint_factory,
            get_embedding_scanner=embedding_factory,
            get_downloaded_version_history_service=history_factory,
        ),
        metadata_provider_factory=_noop_metadata_provider,
    )

    request = make_mocked_request("GET", "/api/models/versions/delete?modelVersionId=42")
    assert lora_scanner.cache_version == 0
    response = await handler.delete_model_version(request)
    assert response.status == 200
    assert lora_scanner.cache_version == 1
    assert checkpoint_scanner.cache_version == 0
    assert embedding_scanner.cache_version == 0
    assert deleted == [("lora", 42)]


# ---------------------------------------------------------------------------
# build_local_hash_cache — version-cached local hash map (plan todo 2)
# ---------------------------------------------------------------------------


def _lora_item(sha256: str = "", autov3: str = "", **extra: Any) -> Dict[str, Any]:
    item: Dict[str, Any] = {
        "sha256": sha256,
        "autov3": autov3,
        "file_path": f"/models/{sha256 or 'x'}.safetensors",
        "file_name": "m",
        "model_name": "m",
    }
    item.update(extra)
    return item


def _make_recipe_scanner(
    lora: DummyScanner, checkpoint: DummyScannerB
) -> RecipeScanner:
    RecipeScanner._instance = None
    return RecipeScanner(
        lora_scanner=lora, checkpoint_scanner=checkpoint  # pyright: ignore[reportArgumentType]
    )


async def test_build_local_hash_cache_has_sha256_autov2_autov3_keys(tmp_path: Path):
    sha256 = "A" * 64
    autov3 = "AAA12BBB34CD"
    lora = _make_scanner([_lora_item(sha256=sha256, autov3=autov3)], str(tmp_path))
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(raw_data=[], folders=[])
    scanner = _make_recipe_scanner(lora, checkpoint)

    result = await scanner.build_local_hash_cache()

    assert set(result) == {sha256.lower(), sha256.lower()[:10], autov3.lower()}
    assert result[sha256.lower()] is lora._cache.raw_data[0]


async def test_build_local_hash_cache_skips_items_without_sha256(tmp_path: Path):
    lora = _make_scanner(
        [
            _lora_item(sha256=""),
            {"file_path": "/models/none.safetensors", "file_name": "n", "model_name": "n"},
            _lora_item(sha256="B" * 64),
        ],
        str(tmp_path),
    )
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(raw_data=[], folders=[])
    scanner = _make_recipe_scanner(lora, checkpoint)

    result = await scanner.build_local_hash_cache()

    assert set(result) == {("B" * 64).lower(), ("B" * 64).lower()[:10]}


async def test_build_local_hash_cache_skips_empty_autov3_keys(tmp_path: Path):
    sha256 = "C" * 64
    lora = _make_scanner([_lora_item(sha256=sha256, autov3="")], str(tmp_path))
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(raw_data=[], folders=[])
    scanner = _make_recipe_scanner(lora, checkpoint)

    result = await scanner.build_local_hash_cache()

    assert "" not in result
    assert set(result) == {sha256.lower(), sha256.lower()[:10]}


async def test_build_local_hash_cache_never_calls_calculate_autov3(
    tmp_path: Path, monkeypatch
):
    from py.utils import file_utils

    called: list[str] = []

    def fake_calculate_autov3(file_path: str) -> str:
        called.append(file_path)
        return "AAABBBCCCDDD"

    monkeypatch.setattr(file_utils, "calculate_autov3", fake_calculate_autov3)

    autov3 = "E" * 12
    lora = _make_scanner([_lora_item(sha256="D" * 64, autov3=autov3)], str(tmp_path))
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(raw_data=[], folders=[])
    scanner = _make_recipe_scanner(lora, checkpoint)

    result = await scanner.build_local_hash_cache()

    assert called == []
    assert autov3.lower() in result


async def test_build_local_hash_cache_reuses_same_object_while_versions_unchanged(
    tmp_path: Path,
):
    lora = _make_scanner([_lora_item(sha256="F" * 64)], str(tmp_path))
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(raw_data=[], folders=[])
    scanner = _make_recipe_scanner(lora, checkpoint)

    first = await scanner.build_local_hash_cache()
    second = await scanner.build_local_hash_cache()

    assert first is second
    assert scanner._local_hash_cache_versions == (
        lora.cache_version,
        checkpoint.cache_version,
    )


async def test_build_local_hash_cache_rebuilds_after_lora_version_change(
    tmp_path: Path,
):
    lora = _make_scanner([_lora_item(sha256="G" * 64)], str(tmp_path))
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(raw_data=[], folders=[])
    scanner = _make_recipe_scanner(lora, checkpoint)

    first = await scanner.build_local_hash_cache()
    lora.bump_cache_version()
    second = await scanner.build_local_hash_cache()

    assert first is not second
    assert first["g" * 64] is second["g" * 64]


async def test_build_local_hash_cache_rebuilds_after_checkpoint_version_change(
    tmp_path: Path,
):
    lora = _make_scanner([], str(tmp_path))
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(
        raw_data=[_lora_item(sha256="H" * 64)], folders=[]
    )
    scanner = _make_recipe_scanner(lora, checkpoint)

    first = await scanner.build_local_hash_cache()
    checkpoint.bump_cache_version()
    second = await scanner.build_local_hash_cache()

    assert first is not second


async def test_build_local_hash_cache_includes_lora_and_checkpoint_items(
    tmp_path: Path,
):
    lora = _make_scanner([_lora_item(sha256="I" * 64, autov3="I1" * 6)], str(tmp_path))
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(
        raw_data=[_lora_item(sha256="J" * 64, autov3="J1" * 6)], folders=[]
    )
    scanner = _make_recipe_scanner(lora, checkpoint)

    result = await scanner.build_local_hash_cache()

    assert ("I" * 64).lower() in result
    assert ("J" * 64).lower() in result
    assert result[("J" * 64).lower()] is checkpoint._cache.raw_data[0]


async def test_build_local_hash_cache_returns_empty_dict_when_no_data(tmp_path: Path):
    lora = _make_scanner([], str(tmp_path))
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(raw_data=[], folders=[])
    scanner = _make_recipe_scanner(lora, checkpoint)

    result = await scanner.build_local_hash_cache()

    assert result == {}


async def test_build_local_hash_cache_single_flight_concurrent_calls(tmp_path: Path):
    lora = _make_scanner([_lora_item(sha256="K" * 64, autov3="K1" * 6)], str(tmp_path))
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(
        raw_data=[_lora_item(sha256="L" * 64)], folders=[]
    )
    scanner = _make_recipe_scanner(lora, checkpoint)

    first, second = await asyncio.gather(
        scanner.build_local_hash_cache(),
        scanner.build_local_hash_cache(),
    )

    assert first is second
    assert ("K" * 64).lower() in first
    assert ("L" * 64).lower() in first


async def test_build_local_hash_cache_handles_missing_scanner(tmp_path: Path):
    lora = _make_scanner([_lora_item(sha256="M" * 64)], str(tmp_path))
    RecipeScanner._instance = None
    scanner = RecipeScanner(lora_scanner=lora)  # pyright: ignore[reportArgumentType]

    result = await scanner.build_local_hash_cache()

    assert ("M" * 64).lower() in result
    assert len(result) == 2


# ---------------------------------------------------------------------------
# rematch matching helpers (plan todo 1)
# ---------------------------------------------------------------------------


def _rematch_item(
    sha256: str = "",
    autov3: str | None = "",
    *,
    sub_type: Any = None,
    civitai_type: Any = None,
    civitai_version_id: Any = None,
    file_name: str = "model.safetensors",
    **extra: Any,
) -> Dict[str, Any]:
    item = _lora_item(sha256=sha256, file_name=file_name, **extra)
    item["autov3"] = autov3
    if sub_type is not None:
        item["sub_type"] = sub_type
    civitai: Dict[str, Any] = {}
    if civitai_version_id is not None:
        civitai["id"] = civitai_version_id
    if civitai_type is not None:
        civitai.setdefault("model", {})["type"] = civitai_type
    if civitai:
        item["civitai"] = civitai
    return item


def _make_rematch_scanner(
    lora_items: list[Dict[str, Any]],
    checkpoint_items: list[Dict[str, Any]],
    tmp_path: Path,
) -> tuple[RecipeScanner, DummyScanner, DummyScannerB]:
    lora = _make_scanner(lora_items, str(tmp_path))
    checkpoint = DummyScannerB(str(tmp_path))
    checkpoint._cache = ModelCache(
        raw_data=[dict(item) for item in checkpoint_items], folders=[]
    )
    return _make_recipe_scanner(lora, checkpoint), lora, checkpoint


# _is_rematch_candidate — candidate filter


async def test_is_rematch_candidate_is_deleted_only_passes(tmp_path: Path):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    assert scanner._is_rematch_candidate(
        {"isDeleted": True, "hash": "abc", "file_name": "m.safetensors"}
    )


async def test_is_rematch_candidate_hash_empty_passes(tmp_path: Path):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    assert scanner._is_rematch_candidate(
        {"hash": "", "modelVersionId": 1, "file_name": "m.safetensors"}
    )


async def test_is_rematch_candidate_file_name_empty_passes(tmp_path: Path):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    assert scanner._is_rematch_candidate(
        {"hash": "abc", "modelVersionId": 1, "file_name": ""}
    )


async def test_is_rematch_candidate_rejects_healthy_entry(tmp_path: Path):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    assert not scanner._is_rematch_candidate({"hash": "abc", "file_name": "m.safetensors"})


async def test_is_rematch_candidate_file_name_only_is_identifier(tmp_path: Path):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    # file_name alone is now an identifier (enables the L4 filename fallback)
    assert scanner._is_rematch_candidate({"isDeleted": True, "file_name": "m.safetensors"})
    assert not scanner._is_rematch_candidate({"isDeleted": True})


async def test_is_rematch_candidate_parser_convention_id_only_passes(tmp_path: Path):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    assert scanner._is_rematch_candidate({"isDeleted": True, "id": 42})


async def test_is_rematch_candidate_rejects_non_dict(tmp_path: Path):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    malformed: Any = "garbage"
    assert not scanner._is_rematch_candidate(malformed)


async def test_is_rematch_candidate_hash_invalid_passes(tmp_path: Path):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    assert scanner._is_rematch_candidate(
        {"hash": "abc", "file_name": "m.safetensors", "hashInvalid": True}
    )


async def test_is_rematch_candidate_healthy_not_in_library_rejected(tmp_path: Path):
    # A healthy entry whose hash is simply absent from the local library
    # (recipe imported without downloading the model) must not become a
    # candidate: its CivitAI-valid hash would be at risk of being
    # overwritten by the imprecise filename fallback.
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    assert not scanner._is_rematch_candidate(
        {"hash": "abc", "file_name": "m.safetensors", "hashInvalid": False}
    )


# _match_rematch_entry — L1 hash-cache lookup


async def test_match_rematch_entry_l1_sha256_key(tmp_path: Path):
    sha256 = ("A" * 64).lower()
    scanner, lora, _ = _make_rematch_scanner(
        [_rematch_item(sha256=sha256, sub_type="lora")], [], tmp_path
    )
    local_cache = await scanner.build_local_hash_cache()

    matched = await scanner._match_rematch_entry(
        {"hash": sha256.upper(), "modelVersionId": 999},
        local_cache,
        {},
        is_checkpoint=False,
    )

    assert matched is lora._cache.raw_data[0]


async def test_match_rematch_entry_l1_sha256_autov2_prefix_key(tmp_path: Path):
    sha256 = ("B" * 64).lower()
    scanner, lora, _ = _make_rematch_scanner(
        [_rematch_item(sha256=sha256, sub_type="lora")], [], tmp_path
    )
    local_cache = await scanner.build_local_hash_cache()

    matched = await scanner._match_rematch_entry(
        {"hash": sha256[:10]}, local_cache, {}, is_checkpoint=False
    )

    assert matched is lora._cache.raw_data[0]


async def test_match_rematch_entry_l1_stored_autov3_key(tmp_path: Path):
    sha256 = ("C" * 64).lower()
    autov3 = "CCCDDDEEEFFF"
    scanner, lora, _ = _make_rematch_scanner(
        [_rematch_item(sha256=sha256, autov3=autov3, sub_type="lora")], [], tmp_path
    )
    local_cache = await scanner.build_local_hash_cache()

    matched = await scanner._match_rematch_entry(
        {"hash": autov3}, local_cache, {}, is_checkpoint=False
    )

    assert matched is lora._cache.raw_data[0]


# _match_rematch_entry — L2 version_index lookup


async def test_match_rematch_entry_l2_lora_via_model_version_id(tmp_path: Path):
    sha256 = ("D" * 64).lower()
    scanner, lora, _ = _make_rematch_scanner(
        [_rematch_item(sha256=sha256, sub_type="lora", civitai_version_id=123)],
        [],
        tmp_path,
    )

    matched = await scanner._match_rematch_entry(
        {"modelVersionId": 123, "file_name": "m.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
    )

    assert matched is lora._cache.raw_data[0]


async def test_match_rematch_entry_l2_lora_via_id_key(tmp_path: Path):
    sha256 = ("E" * 64).lower()
    scanner, lora, _ = _make_rematch_scanner(
        [_rematch_item(sha256=sha256, sub_type="lora", civitai_version_id=456)],
        [],
        tmp_path,
    )

    matched = await scanner._match_rematch_entry(
        {"id": 456, "file_name": "m.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
    )

    assert matched is lora._cache.raw_data[0]


async def test_match_rematch_entry_l2_checkpoint_via_version_index(tmp_path: Path):
    sha256 = ("F" * 64).lower()
    scanner, _, checkpoint = _make_rematch_scanner(
        [],
        [_rematch_item(sha256=sha256, sub_type="checkpoint", civitai_version_id=789)],
        tmp_path,
    )

    matched = await scanner._match_rematch_entry(
        {"modelVersionId": 789, "file_name": "m.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=True,
    )

    assert matched is checkpoint._cache.raw_data[0]


# _match_rematch_entry — L3 computed autov3 lookup


async def test_match_rematch_entry_l3_computed_autov3_renamed_file(
    tmp_path: Path, monkeypatch
):
    from py.services import recipe_scanner as recipe_scanner_module

    entry_hash = "AABBCCDDEEFF"
    monkeypatch.setattr(
        recipe_scanner_module, "calculate_autov3", lambda path: entry_hash.lower()
    )

    item = _rematch_item(
        sha256=("G" * 64).lower(), file_name="renamed.safetensors", sub_type="lora"
    )
    del item["autov3"]  # unchecked state
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    autov3_cache = await scanner._build_rematch_autov3_cache()

    matched = await scanner._match_rematch_entry(
        {"hash": entry_hash, "file_name": "original-name.safetensors", "isDeleted": True},
        {},
        autov3_cache,
        is_checkpoint=False,
    )

    assert matched is not None
    assert matched["file_name"] == "renamed.safetensors"


async def test_match_rematch_entry_l3_skips_empty_autov3_terminal(
    tmp_path: Path, monkeypatch
):
    from py.services import recipe_scanner as recipe_scanner_module

    entry_hash = "H1H2H3H4H5H6"
    called: list[str] = []
    monkeypatch.setattr(
        recipe_scanner_module,
        "calculate_autov3",
        lambda path: (called.append(path), entry_hash.lower())[1],
    )

    item = _rematch_item(
        sha256=("H" * 64).lower(), autov3="", file_name="m.safetensors", sub_type="lora"
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    autov3_cache = await scanner._build_rematch_autov3_cache()

    matched = await scanner._match_rematch_entry(
        {"hash": entry_hash}, {}, autov3_cache, is_checkpoint=False
    )

    assert matched is None
    assert called == []  # '' is terminal — must never be recomputed


async def test_match_rematch_entry_l3_skipped_for_non_12_char_hash(
    tmp_path: Path, monkeypatch
):
    from py.services import recipe_scanner as recipe_scanner_module

    entry_hash = "AA"
    monkeypatch.setattr(
        recipe_scanner_module, "calculate_autov3", lambda path: entry_hash.lower()
    )

    item = _rematch_item(sha256=("I" * 64).lower(), file_name="m.safetensors", sub_type="lora")
    del item["autov3"]
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    autov3_cache = await scanner._build_rematch_autov3_cache()

    matched = await scanner._match_rematch_entry(
        {"hash": entry_hash}, {}, autov3_cache, is_checkpoint=False
    )

    assert matched is None


# _match_rematch_entry — precedence


async def test_match_rematch_entry_precedence_l1_wins_over_l2(tmp_path: Path):
    sha256 = ("J" * 64).lower()
    scanner, lora, _ = _make_rematch_scanner(
        [
            _rematch_item(
                sha256=sha256, sub_type="lora", civitai_version_id=500, file_name="l1-item.safetensors"
            ),
            _rematch_item(
                sha256=("K" * 64).lower(),
                sub_type="lora",
                civitai_version_id=999,
                file_name="l2-item.safetensors",
            ),
        ],
        [],
        tmp_path,
    )
    local_cache = await scanner.build_local_hash_cache()

    matched = await scanner._match_rematch_entry(
        {"hash": sha256, "modelVersionId": 999, "file_name": "m.safetensors", "isDeleted": True},
        local_cache,
        {},
        is_checkpoint=False,
    )

    assert matched is lora._cache.raw_data[0]
    assert lora._cache.raw_data[0]["file_name"] == "l1-item.safetensors"


# _match_rematch_entry — type gate


async def test_match_rematch_type_gate_checkpoint_accepts_sub_type(tmp_path: Path):
    for sub_type in ("checkpoint", "diffusion_model"):
        item = _rematch_item(sha256=("M" * 64).lower(), sub_type=sub_type)
        scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
        local_cache = await scanner.build_local_hash_cache()

        matched = await scanner._match_rematch_entry(
            {"hash": ("M" * 64).lower()}, local_cache, {}, is_checkpoint=True
        )

        assert matched is not None


async def test_match_rematch_type_gate_checkpoint_accepts_diffusion_model_civitai_alias(
    tmp_path: Path,
):
    item = _rematch_item(sha256=("N" * 64).lower(), civitai_type="DiffusionModel")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    local_cache = await scanner.build_local_hash_cache()

    matched = await scanner._match_rematch_entry(
        {"hash": ("N" * 64).lower()}, local_cache, {}, is_checkpoint=True
    )

    assert matched is not None


async def test_match_rematch_type_gate_checkpoint_rejects_lora_typed_item(tmp_path: Path):
    cases = [("LORA", None), ("lora", None), (None, "LORA"), (None, "lora")]
    for sub_type, civitai_type in cases:
        item = _rematch_item(
            sha256=("O" * 64).lower(), sub_type=sub_type, civitai_type=civitai_type
        )
        scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
        local_cache = await scanner.build_local_hash_cache()

        matched = await scanner._match_rematch_entry(
            {"hash": ("O" * 64).lower()}, local_cache, {}, is_checkpoint=True
        )

        assert matched is None


async def test_match_rematch_type_gate_lora_rejects_checkpoint_sub_type_no_civitai(
    tmp_path: Path,
):
    item = _rematch_item(sha256=("P" * 64).lower(), sub_type="checkpoint")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    local_cache = await scanner.build_local_hash_cache()

    matched = await scanner._match_rematch_entry(
        {"hash": ("P" * 64).lower()}, local_cache, {}, is_checkpoint=False
    )

    assert matched is None


async def test_match_rematch_type_gate_lora_rejects_checkpoint_civitai_type(tmp_path: Path):
    item = _rematch_item(sha256=("Q" * 64).lower(), civitai_type="checkpoint")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    local_cache = await scanner.build_local_hash_cache()

    matched = await scanner._match_rematch_entry(
        {"hash": ("Q" * 64).lower()}, local_cache, {}, is_checkpoint=False
    )

    assert matched is None


async def test_match_rematch_type_gate_type_less_item_accepted_for_both(tmp_path: Path):
    for is_checkpoint in (False, True):
        item = _rematch_item(sha256=("R" * 64).lower())
        scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
        local_cache = await scanner.build_local_hash_cache()

        matched = await scanner._match_rematch_entry(
            {"hash": ("R" * 64).lower()}, local_cache, {}, is_checkpoint=is_checkpoint
        )

        assert matched is not None


async def test_match_rematch_type_gate_lora_accepts_lora_typed_item(tmp_path: Path):
    cases = [("lora", None), ("locon", None), ("dora", None), (None, "LORA"), (None, "lora")]
    for sub_type, civitai_type in cases:
        item = _rematch_item(
            sha256=("S" * 64).lower(), sub_type=sub_type, civitai_type=civitai_type
        )
        scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
        local_cache = await scanner.build_local_hash_cache()

        matched = await scanner._match_rematch_entry(
            {"hash": ("S" * 64).lower()}, local_cache, {}, is_checkpoint=False
        )

        assert matched is not None


# _match_rematch_entry — L4 filename fallback (conservative)


async def test_match_rematch_entry_l4_filename_hit(tmp_path: Path):
    item = _rematch_item(
        sha256=("T1" * 32).lower(),
        sub_type="lora",
        base_model="SD 1.5",
        file_name="detail.safetensors",
    )
    scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "detail.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert matched is lora._cache.raw_data[0]
    assert level == "L4"


async def test_match_rematch_entry_l4_filename_normalized_key(tmp_path: Path):
    # case, path and extension differences are normalized on both sides
    item = _rematch_item(
        sha256=("T2" * 32).lower(),
        sub_type="lora",
        base_model="SDXL",
        file_name="My_Mix.safetensors",
    )
    scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "subdir/my_mix", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="sdxl",
    )

    assert matched is lora._cache.raw_data[0]
    assert level == "L4"


async def test_match_rematch_entry_l4_dotted_stem_no_collision(tmp_path: Path):
    # "my.mix" (dotted stem) and "my" are distinct names — splitext-style
    # stripping would collapse both to "my" and bind the wrong model as a
    # unique candidate.
    item = _rematch_item(
        sha256=("T2A" * 32).lower(),
        sub_type="lora",
        base_model="SD 1.5",
        file_name="my.mix",
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "my", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert (matched, level) == (None, None)


async def test_match_rematch_entry_l4_extension_bearing_entry_reconciled(tmp_path: Path):
    # extension-bearing entry names reconcile with extensionless items
    item = _rematch_item(
        sha256=("T2B" * 32).lower(),
        sub_type="lora",
        base_model="SD 1.5",
        file_name="my.mix.v1",
    )
    scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "my.mix.v1.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert matched is lora._cache.raw_data[0]
    assert level == "L4"


async def test_match_rematch_entry_l4_base_model_mismatch_rejects(tmp_path: Path):
    item = _rematch_item(
        sha256=("T3" * 32).lower(),
        sub_type="lora",
        base_model="SD 1.5",
        file_name="detail.safetensors",
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "detail.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SDXL",
    )

    assert (matched, level) == (None, None)


async def test_match_rematch_entry_l4_recipe_base_model_unknown_rejects(tmp_path: Path):
    item = _rematch_item(
        sha256=("T4" * 32).lower(),
        sub_type="lora",
        base_model="SD 1.5",
        file_name="detail.safetensors",
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "detail.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="",
    )

    assert (matched, level) == (None, None)


async def test_match_rematch_entry_l4_item_base_model_unknown_rejects(tmp_path: Path):
    item = _rematch_item(
        sha256=("T5" * 32).lower(), sub_type="lora", file_name="detail.safetensors"
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "detail.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert (matched, level) == (None, None)


async def test_match_rematch_entry_l4_ambiguous_same_base_model_rejects(tmp_path: Path):
    items = [
        _rematch_item(
            sha256=("T6" * 32).lower(),
            sub_type="lora",
            base_model="SD 1.5",
            file_name="detail.safetensors",
        ),
        _rematch_item(
            sha256=("T7" * 32).lower(),
            sub_type="lora",
            base_model="SD 1.5",
            file_name="detail.safetensors",
        ),
    ]
    scanner, _, _ = _make_rematch_scanner(items, [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "detail.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert (matched, level) == (None, None)


async def test_match_rematch_entry_l4_ambiguity_resolved_by_base_model(tmp_path: Path):
    sdxl_item = _rematch_item(
        sha256=("T8" * 32).lower(),
        sub_type="lora",
        base_model="SDXL",
        file_name="detail.safetensors",
    )
    sd15_item = _rematch_item(
        sha256=("T9" * 32).lower(),
        sub_type="lora",
        base_model="SD 1.5",
        file_name="detail.safetensors",
    )
    scanner, lora, _ = _make_rematch_scanner([sdxl_item, sd15_item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "detail.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SDXL",
    )

    assert matched is lora._cache.raw_data[0]
    assert level == "L4"


async def test_match_rematch_entry_l4_type_gate_rejects(tmp_path: Path):
    # a checkpoint-typed item with a matching name must not satisfy a lora entry
    item = _rematch_item(
        sha256=("TA" * 32).lower(),
        sub_type="checkpoint",
        base_model="SD 1.5",
        file_name="detail.safetensors",
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "detail.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert (matched, level) == (None, None)


async def test_match_rematch_entry_l4_checkpoint_slot_rejects_type_less_candidate(
    tmp_path: Path,
):
    # lora raw items often carry no sub_type; an unknown-type candidate must
    # not be bound into a checkpoint slot
    item = _rematch_item(
        sha256=("TA1" * 32).lower(),
        base_model="SD 1.5",
        file_name="realistic.safetensors",
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "realistic.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=True,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert (matched, level) == (None, None)


async def test_match_rematch_entry_l4_checkpoint_slot_accepts_typed_candidate(
    tmp_path: Path,
):
    item = _rematch_item(
        sha256=("TA2" * 32).lower(),
        sub_type="checkpoint",
        base_model="SD 1.5",
        file_name="realistic.safetensors",
    )
    scanner, _, checkpoint = _make_rematch_scanner([], [item], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "realistic.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=True,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert matched is checkpoint._cache.raw_data[0]
    assert level == "L4"


async def test_match_rematch_entry_l4_lora_slot_accepts_type_less_candidate(tmp_path: Path):
    # asymmetry: lora slots still accept type-less candidates (the norm for
    # lora raw items); checkpoint items always carry sub_type, so the type
    # gate alone protects the reverse direction
    item = _rematch_item(
        sha256=("TA3" * 32).lower(), base_model="SD 1.5", file_name="detail.safetensors"
    )
    scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "detail.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert matched is lora._cache.raw_data[0]
    assert level == "L4"


async def test_rematch_l4_entry_base_model_preferred_over_recipe(tmp_path: Path, monkeypatch):
    # a Pony lora inside an SD 1.5 recipe matches via its own baseModel
    item = _rematch_item(
        sha256=("TB1" * 32).lower(),
        sub_type="lora",
        base_model="Pony",
        file_name="pony.safetensors",
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()
    saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
    await _spy_fts(scanner, monkeypatch)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "base_model": "SD 1.5",
        "loras": [
            {"file_name": "pony.safetensors", "isDeleted": True, "baseModel": "Pony"}
        ],
    }
    rematched, _errors, details = await scanner._rematch_single_recipe(
        recipe, {}, {}, filename_cache
    )

    assert rematched == 1
    assert details["matched"][0]["match_level"] == "L4"
    assert recipe["loras"][0]["hash"] == ("TB1" * 32).lower()
    assert saved == [recipe]


async def test_rematch_l4_entry_base_model_missing_falls_back_to_recipe(
    tmp_path: Path, monkeypatch
):
    # without entry-level baseModel the recipe-level gate governs: a Pony
    # candidate must not match an SD 1.5 recipe
    item = _rematch_item(
        sha256=("TB2" * 32).lower(),
        sub_type="lora",
        base_model="Pony",
        file_name="pony.safetensors",
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()
    await _spy_rematch_persistence(scanner, monkeypatch)
    await _spy_fts(scanner, monkeypatch)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "base_model": "SD 1.5",
        "loras": [{"file_name": "pony.safetensors", "isDeleted": True}],
    }
    rematched, _errors, details = await scanner._rematch_single_recipe(
        recipe, {}, {}, filename_cache
    )

    assert rematched == 0
    assert details["unresolved"] == [{"type": "lora", "entry": "pony.safetensors"}]


async def test_match_rematch_entry_l4_no_filename_hit(tmp_path: Path):
    item = _rematch_item(
        sha256=("TB" * 32).lower(),
        sub_type="lora",
        base_model="SD 1.5",
        file_name="other.safetensors",
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"file_name": "missing.safetensors", "isDeleted": True},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert (matched, level) == (None, None)


async def test_match_rematch_entry_l4_entry_without_file_name_skipped(tmp_path: Path):
    item = _rematch_item(
        sha256=("TC" * 32).lower(),
        sub_type="lora",
        base_model="SD 1.5",
        file_name="detail.safetensors",
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"isDeleted": True, "hash": ""},
        {},
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert (matched, level) == (None, None)


async def test_match_rematch_entry_l1_wins_over_l4_filename(tmp_path: Path):
    # a valid stored hash resolves via L1 even when the filename would match
    sha256 = ("TD" * 32).lower()
    l1_item = _rematch_item(
        sha256=sha256, sub_type="lora", base_model="SD 1.5", file_name="l1-item.safetensors"
    )
    l4_item = _rematch_item(
        sha256=("TE" * 32).lower(),
        sub_type="lora",
        base_model="SD 1.5",
        file_name="detail.safetensors",
    )
    scanner, lora, _ = _make_rematch_scanner([l1_item, l4_item], [], tmp_path)
    local_cache = await scanner.build_local_hash_cache()
    filename_cache = await scanner._build_local_filename_cache()

    matched, level = await scanner._match_rematch_entry_with_level(
        {"hash": sha256, "file_name": "detail.safetensors", "isDeleted": True},
        local_cache,
        {},
        is_checkpoint=False,
        filename_cache=filename_cache,
        recipe_base_model="SD 1.5",
    )

    assert matched is lora._cache.raw_data[0]
    assert level == "L1"


# _build_local_filename_cache


async def test_build_local_filename_cache_normalized_keys_sha256_only(tmp_path: Path):
    lora_items = [
        _rematch_item(sha256=("TF" * 32).lower(), file_name="Case.Mix.safetensors"),
        _rematch_item(sha256="", file_name="no-hash.safetensors"),  # skipped
    ]
    checkpoint_items = [
        _rematch_item(
            sha256=("TG" * 32).lower(), sub_type="checkpoint", file_name="Base.safetensors"
        )
    ]
    scanner, lora, checkpoint = _make_rematch_scanner(
        lora_items, checkpoint_items, tmp_path
    )

    result = await scanner._build_local_filename_cache()

    assert set(result) == {"case.mix", "base"}
    assert len(result["case.mix"]) == 1
    assert result["case.mix"][0] is lora._cache.raw_data[0]
    # checkpoint items are indexed too (type-blind cache)
    assert result["base"][0] is checkpoint._cache.raw_data[0]


# _build_rematch_autov3_cache


async def test_build_rematch_autov3_cache_computes_only_absent_none_items(
    tmp_path: Path, monkeypatch
):
    from py.services import recipe_scanner as recipe_scanner_module

    called: list[str] = []

    def fake_calculate_autov3(file_path: str) -> str:
        called.append(file_path)
        return ("AAA" + str(len(called)).zfill(9))[:12]

    monkeypatch.setattr(recipe_scanner_module, "calculate_autov3", fake_calculate_autov3)

    lora_items = [
        _rematch_item(sha256=("T" * 64).lower(), autov3=""),  # terminal '' — skip
        _rematch_item(sha256=("U" * 64).lower(), autov3=None),  # None — compute
        _rematch_item(sha256=("V" * 64).lower(), autov3="V1V2V3V4V5V6"),  # stored — skip
        _rematch_item(sha256=("W" * 64).lower()),  # absent key — compute
    ]
    del lora_items[3]["autov3"]
    checkpoint_items = [
        _rematch_item(sha256=("X" * 64).lower(), autov3=None),  # checkpoint too — compute
    ]
    scanner, _, _ = _make_rematch_scanner(lora_items, checkpoint_items, tmp_path)

    result = await scanner._build_rematch_autov3_cache()

    assert set(called) == {
        lora_items[1]["file_path"],
        lora_items[3]["file_path"],
        checkpoint_items[0]["file_path"],
    }
    assert set(result) == {"aaa000000001", "aaa000000002", "aaa000000003"}
    assert result["aaa000000001"] is scanner._lora_scanner._cache.raw_data[1]
    # Items must not be mutated while building the cache
    assert "autov3" not in scanner._lora_scanner._cache.raw_data[3]


async def test_build_rematch_autov3_cache_does_not_persist(tmp_path: Path, monkeypatch):
    from py.services import recipe_scanner as recipe_scanner_module

    monkeypatch.setattr(
        recipe_scanner_module, "calculate_autov3", lambda path: "AAABBBCCCDDD"
    )
    item = _rematch_item(sha256=("Z" * 64).lower())
    del item["autov3"]
    scanner, lora, checkpoint = _make_rematch_scanner([item], [], tmp_path)
    persist_calls: list[Any] = []
    monkeypatch.setattr(
        lora,
        "update_autov3_for_model",
        lambda *args, **kwargs: persist_calls.append((args, kwargs)),
    )
    monkeypatch.setattr(
        checkpoint,
        "update_autov3_for_model",
        lambda *args, **kwargs: persist_calls.append((args, kwargs)),
    )

    result = await scanner._build_rematch_autov3_cache()

    assert persist_calls == []
    assert result == {"aaabbbcccddd": scanner._lora_scanner._cache.raw_data[0]}


# ---------------------------------------------------------------------------
# rematch_recipe_by_id — single-recipe rematch core (plan todo 2)
# ---------------------------------------------------------------------------


def _set_recipe_cache(scanner: RecipeScanner, recipes: list[Dict[str, Any]]) -> None:
    """Place recipes directly into the scanner's recipe cache (no copy)."""
    from py.services.recipe_cache import RecipeCache

    scanner._cache = RecipeCache(
        raw_data=recipes, sorted_by_name=[], sorted_by_date=[]
    )


async def _spy_rematch_persistence(
    scanner: RecipeScanner, monkeypatch, *, save_result: bool = True
) -> tuple[list[Dict[str, Any]], Dict[str, Any]]:
    """Stub the persist path so rematch tests avoid the filesystem.

    Returns (saved_calls, enriched_dict) — the enriched dict is what the
    mocked get_recipe_by_id returns for the changed+success path.
    """
    saved: list[Dict[str, Any]] = []

    async def fake_save(rcp: Dict[str, Any]) -> bool:
        saved.append(rcp)
        return save_result

    monkeypatch.setattr(scanner, "_save_recipe_persistently", fake_save)

    enriched: Dict[str, Any] = {
        "id": "enriched",
        "file_url": "/loras_static/preview/enriched.png",
    }

    async def fake_get(rid: str) -> Dict[str, Any]:
        return enriched

    monkeypatch.setattr(scanner, "get_recipe_by_id", fake_get)
    return saved, enriched


async def _spy_fts(scanner: RecipeScanner, monkeypatch) -> list[tuple[Any, str]]:
    calls: list[tuple[Any, str]] = []

    def fake_fts(recipe: Any, operation: str) -> None:
        calls.append((recipe, operation))

    monkeypatch.setattr(scanner, "_update_fts_index_for_recipe", fake_fts)
    return calls


async def _spy_resort(scanner: RecipeScanner, monkeypatch) -> list[bool]:
    calls: list[bool] = []

    def fake_resort() -> None:
        calls.append(True)

    monkeypatch.setattr(scanner, "_schedule_resort", fake_resort)
    return calls


def _civitai_lora_item(
    *,
    sha256: str = "",
    version_id: Any = None,
    name: str = "",
    model_name: str = "m",
    file_name: str = "m.safetensors",
    **extra: Any,
) -> Dict[str, Any]:
    item = _rematch_item(
        sha256=sha256,
        sub_type="lora",
        civitai_version_id=version_id,
        file_name=file_name,
        model_name=model_name,
        **extra,
    )
    if name:
        item["civitai"].setdefault("name", name)
    return item


def _civitai_checkpoint_item(
    *,
    sha256: str = "",
    version_id: Any = None,
    name: str = "",
    model_name: str = "m",
    file_name: str = "cp.safetensors",
    base_model: str = "",
) -> Dict[str, Any]:
    item = _rematch_item(
        sha256=sha256,
        sub_type="checkpoint",
        civitai_version_id=version_id,
        file_name=file_name,
        model_name=model_name,
    )
    if base_model:
        item["base_model"] = base_model
    if name:
        item["civitai"].setdefault("name", name)
    return item


# Acceptance criterion (1): lora entry rematched via L1


async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypatch):
    sha256 = ("A" * 64).lower()
    item = _civitai_lora_item(
        sha256=sha256,
        version_id=111,
        name="v1.0",
        model_name="Lora Model",
        file_name="m.safetensors",
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "modified": 123.0,
        "fingerprint": "stale",
        "loras": [
            {
                "isDeleted": True,
                "hash": sha256.upper(),
                "file_name": "old.safetensors",
                "modelVersionId": 0,
            }
        ],
    }
    _set_recipe_cache(scanner, [recipe])

    saved, enriched = await _spy_rematch_persistence(scanner, monkeypatch)
    fts_calls = await _spy_fts(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    assert result["rematched"] == 1
    assert result["skipped"] == 0
    assert result["matched_recipes"] == 1
    assert result["matched_entries"] == 1
    assert result["unresolved_recipes"] == 0
    assert result["unresolved_entries"] == 0
    assert result["details"]["matched"] == [
        {
            "type": "lora",
            "entry": "old.safetensors",
            "file_name": "m.safetensors",
            "match_level": "L1",
        }
    ]
    assert result["recipe"] is enriched
    assert result["recipe"]["file_url"] == "/loras_static/preview/enriched.png"

    entry = recipe["loras"][0]
    assert entry["isDeleted"] is False
    assert entry["hash"] == sha256
    assert entry["file_name"] == "m.safetensors"
    assert entry["modelName"] == "Lora Model"
    assert entry["modelVersionName"] == "v1.0"
    assert entry["modelVersionId"] == 111

    assert saved == [recipe]
    assert recipe["modified"] == 123.0  # Metis F8 — never bumped
    assert recipe["fingerprint"] == calculate_recipe_fingerprint([entry])
    assert fts_calls == [(recipe, "update")]
    assert resort_calls == []  # Metis F1 — hoisted to public entry points


# Acceptance criterion (2): checkpoint entry rematched via L2 — parser style


async def test_rematch_recipe_by_id_checkpoint_l2_parser_style_backfill(
    tmp_path: Path, monkeypatch
):
    sha256 = ("C" * 64).lower()
    cp_item = _civitai_checkpoint_item(
        sha256=sha256,
        version_id=222,
        name="v2.0",
        model_name="Checkpoint Model",
        file_name="cp.safetensors",
        base_model="SD 1.5",
    )
    scanner, _, _ = _make_rematch_scanner([], [cp_item], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "checkpoint": {
            "isDeleted": True,
            "modelVersionId": 222,
            "type": "checkpoint",
            "name": "old-name",
            "version": "old-v",
            "baseModel": "SD 1.5",
            "file_name": "old.safetensors",
            "hash": "",
        },
        "loras": [],
    }
    _set_recipe_cache(scanner, [recipe])
    saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    assert result["rematched"] == 1

    cp = recipe["checkpoint"]
    assert cp["isDeleted"] is False
    assert cp["hash"] == sha256
    assert cp["file_name"] == "cp.safetensors"
    assert cp["name"] == "Checkpoint Model"
    assert cp["version"] == "v2.0"
    assert cp["baseModel"] == "SD 1.5"
    assert cp["modelVersionId"] == 222  # identifier key convention preserved
    assert "modelName" not in cp  # Oracle R2-F5 — never added to parser-style
    assert "modelVersionName" not in cp
    assert saved == [recipe]


# Acceptance criterion (2): checkpoint widget-style names updated (Oracle R3-F2)


async def test_rematch_recipe_by_id_checkpoint_widget_style_names_updated(
    tmp_path: Path, monkeypatch
):
    cp_item = _civitai_checkpoint_item(
        sha256=("D" * 64).lower(),
        version_id=333,
        name="v3.0",
        model_name="Widget Model",
    )
    scanner, _, _ = _make_rematch_scanner([], [cp_item], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "checkpoint": {
            "isDeleted": True,
            "modelVersionId": 333,
            "modelName": "stale-name",
            "modelVersionName": "stale-v",
            "file_name": "old.safetensors",
            "hash": "",
        },
        "loras": [],
    }
    _set_recipe_cache(scanner, [recipe])
    await _spy_rematch_persistence(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    cp = recipe["checkpoint"]
    assert cp["modelName"] == "Widget Model"
    assert cp["modelVersionName"] == "v3.0"


# Acceptance criterion (3): PENDING-HASH GUARD (Oracle R2-F3)


async def test_rematch_recipe_by_id_pending_hash_guard_preserves_existing_hash(
    tmp_path: Path, monkeypatch
):
    # Item matched via L2 carries sha256 == "" (hash_status pending).
    item = _civitai_lora_item(
        sha256="", version_id=444, name="v4", model_name="Pending", file_name="p.safetensors"
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    stored_hash = "aabbccddeeff"
    recipe: Dict[str, Any] = {
        "id": "r1",
        "loras": [
            {
                "isDeleted": True,
                "hash": stored_hash,
                "modelVersionId": 444,
                "file_name": "old.safetensors",
            }
        ],
    }
    _set_recipe_cache(scanner, [recipe])
    await _spy_rematch_persistence(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    assert result["rematched"] == 1
    entry = recipe["loras"][0]
    assert entry["hash"] == stored_hash  # preserved, NOT wiped to ""
    assert entry["isDeleted"] is False
    assert entry["file_name"] == "p.safetensors"


# Acceptance criterion (4)+(5): exclude preserved (Metis F4), modified untouched (Metis F8)


async def test_rematch_recipe_by_id_preserves_exclude_and_modified(
    tmp_path: Path, monkeypatch
):
    sha256 = ("E" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=555, name="v5")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "modified": 999.0,
        "loras": [
            {
                "isDeleted": True,
                "hash": sha256,
                "file_name": "old.safetensors",
                "exclude": True,
            }
        ],
    }
    _set_recipe_cache(scanner, [recipe])
    await _spy_rematch_persistence(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    entry = recipe["loras"][0]
    assert entry["exclude"] is True
    assert recipe["modified"] == 999.0


# Acceptance criterion (6): no-change recipe → no persistence, rematched=0


async def test_rematch_recipe_by_id_no_change_skips_persistence(
    tmp_path: Path, monkeypatch
):
    sha256 = ("F" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=666, name="v6")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "fingerprint": "original",
        "loras": [
            # Healthy entry — not a rematch candidate, nothing to change.
            {"isDeleted": False, "hash": sha256, "file_name": "m.safetensors"}
        ],
    }
    _set_recipe_cache(scanner, [recipe])

    saved, enriched = await _spy_rematch_persistence(scanner, monkeypatch)
    fts_calls = await _spy_fts(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    assert result["rematched"] == 0
    assert result["skipped"] == 1
    assert result["recipe"] is recipe  # raw cache dict, not enriched
    assert saved == []
    assert fts_calls == []
    assert resort_calls == []
    assert recipe["fingerprint"] == "original"  # no fingerprint churn


# Acceptance criterion (7): fingerprint recomputed on hash change


async def test_rematch_recipe_by_id_fingerprint_recomputed_on_hash_change(
    tmp_path: Path, monkeypatch
):
    sha256 = ("G" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=777, name="v7")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "loras": [
            {
                "isDeleted": True,
                "hash": "oldhash123",
                "modelVersionId": 777,
                "file_name": "old.safetensors",
            }
        ],
    }
    _set_recipe_cache(scanner, [recipe])
    original_fingerprint = calculate_recipe_fingerprint([dict(recipe["loras"][0])])
    await _spy_rematch_persistence(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    entry = recipe["loras"][0]
    assert entry["hash"] == sha256
    assert recipe["fingerprint"] == calculate_recipe_fingerprint([entry])
    assert recipe["fingerprint"] != original_fingerprint


# Acceptance criterion (7): fingerprint UNCHANGED when only isDeleted flips (Metis F16a)


async def test_rematch_recipe_by_id_fingerprint_unchanged_when_only_deleted_flips(
    tmp_path: Path, monkeypatch
):
    sha256 = ("H" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=888, name="v8")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "loras": [
            {"isDeleted": True, "hash": sha256, "file_name": "old.safetensors"}
        ],
    }
    _set_recipe_cache(scanner, [recipe])
    original_fingerprint = calculate_recipe_fingerprint([dict(recipe["loras"][0])])
    await _spy_rematch_persistence(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    assert result["rematched"] == 1
    assert recipe["loras"][0]["isDeleted"] is False
    assert recipe["fingerprint"] == original_fingerprint  # hash-only fingerprint


# Acceptance criterion (8): hash-empty entry via L2 → fingerprint form changes (Metis F16b)


async def test_rematch_recipe_by_id_fingerprint_changes_from_version_fallback_to_hash(
    tmp_path: Path, monkeypatch
):
    sha256 = ("I" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=999, name="v9")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "loras": [
            {"isDeleted": True, "hash": "", "modelVersionId": 999, "file_name": "old.safetensors"}
        ],
    }
    _set_recipe_cache(scanner, [recipe])
    original_fingerprint = calculate_recipe_fingerprint([dict(recipe["loras"][0])])
    assert original_fingerprint == "999:1.0"  # modelVersionId fallback form

    await _spy_rematch_persistence(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    entry = recipe["loras"][0]
    assert entry["hash"] == sha256
    assert recipe["fingerprint"] == calculate_recipe_fingerprint([entry])
    assert recipe["fingerprint"] != original_fingerprint


# Acceptance criterion (9): FTS called only when persistence returned True
# (False branch covered by the persist-failure test below)


async def test_rematch_recipe_by_id_fts_skipped_when_persistence_false_stub(
    tmp_path: Path, monkeypatch
):
    sha256 = ("J" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=1000, name="v10")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "loras": [
            {"isDeleted": True, "hash": sha256, "file_name": "old.safetensors"}
        ],
    }
    _set_recipe_cache(scanner, [recipe])

    saved, _ = await _spy_rematch_persistence(scanner, monkeypatch, save_result=False)
    fts_calls = await _spy_fts(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is False
    assert result["errors"] == 1
    assert result["rematched"] == 0
    assert result["skipped"] == 0
    assert result["recipe"] is recipe
    assert "error" in result
    assert saved == [recipe]
    assert fts_calls == []
    assert resort_calls == []


# Acceptance criterion (10): PERSIST-FAILURE via EXIF raise (Oracle R1-F2/R2-F2/R4-F2)


async def test_rematch_recipe_by_id_exif_raise_counts_error_and_skips_fts(
    tmp_path: Path, monkeypatch
):
    sha256 = ("K" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=1001, name="v11")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "loras": [
            {"isDeleted": True, "hash": sha256, "file_name": "old.safetensors"}
        ],
    }
    _set_recipe_cache(scanner, [recipe])

    # Real persist path: JSON file + existing image so EXIF is reached.
    recipes_dir = tmp_path / "recipes"
    recipes_dir.mkdir(exist_ok=True)
    json_path = recipes_dir / "r1.recipe.json"
    json_path.write_text(json.dumps(recipe))
    image = tmp_path / "r1.png"
    image.write_bytes(b"fake-png")
    recipe["file_path"] = str(image)

    async def fake_get_json_path(rid: str) -> str:
        return str(json_path)

    monkeypatch.setattr(scanner, "get_recipe_json_path", fake_get_json_path)

    def _boom_exif(image_path, recipe_data):
        raise RuntimeError("exif boom")

    monkeypatch.setattr(
        "py.utils.exif_utils.ExifUtils.append_recipe_metadata", _boom_exif
    )
    fts_calls = await _spy_fts(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is False
    assert result["errors"] == 1
    assert result["rematched"] == 0
    assert result["skipped"] == 0
    assert result["recipe"] is recipe
    assert "error" in result
    assert fts_calls == []
    assert resort_calls == []


# Acceptance criterion (11): LEGACY STRING CHECKPOINT (Oracle R1-F3)


async def test_rematch_recipe_by_id_legacy_string_checkpoint_skipped(
    tmp_path: Path, monkeypatch
):
    sha256 = ("L" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=1002, name="v12")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "checkpoint": "name.safetensors",  # bare string — must not crash
        "loras": [
            {"isDeleted": True, "hash": sha256, "file_name": "old.safetensors"}
        ],
    }
    _set_recipe_cache(scanner, [recipe])
    saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    assert result["rematched"] == 1  # loras still processed
    assert recipe["checkpoint"] == "name.safetensors"  # skipped silently
    assert recipe["loras"][0]["isDeleted"] is False
    assert saved == [recipe]


# Acceptance criterion (12): recipe not found → RecipeNotFoundError (handler → 404)


async def test_rematch_recipe_by_id_not_found_raises(tmp_path: Path):
    from py.services.recipes.errors import RecipeNotFoundError

    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    _set_recipe_cache(scanner, [])

    with pytest.raises(RecipeNotFoundError):
        await scanner.rematch_recipe_by_id("missing")


# Acceptance criterion (13): EXIF written once per changed recipe


async def test_rematch_recipe_by_id_exif_written_once(tmp_path: Path, monkeypatch):
    sha256 = ("M" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=1003, name="v13")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "loras": [
            {"isDeleted": True, "hash": sha256, "file_name": "old.safetensors"}
        ],
    }
    _set_recipe_cache(scanner, [recipe])

    recipes_dir = tmp_path / "recipes"
    recipes_dir.mkdir(exist_ok=True)
    json_path = recipes_dir / "r1.recipe.json"
    json_path.write_text(json.dumps(recipe))
    image = tmp_path / "r1.png"
    image.write_bytes(b"fake-png")
    recipe["file_path"] = str(image)

    async def fake_get_json_path(rid: str) -> str:
        return str(json_path)

    monkeypatch.setattr(scanner, "get_recipe_json_path", fake_get_json_path)

    exif_calls: list[tuple[Any, Any]] = []

    def _spy_exif(image_path, recipe_data):
        exif_calls.append((image_path, recipe_data))

    monkeypatch.setattr(
        "py.utils.exif_utils.ExifUtils.append_recipe_metadata", _spy_exif
    )

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    assert len(exif_calls) == 1
    assert exif_calls[0][0] == str(image)
    assert exif_calls[0][1] is recipe


# Acceptance criterion (14): response recipe is the enriched dict (Metis F14)


async def test_rematch_recipe_by_id_response_recipe_enriched(tmp_path: Path, monkeypatch):
    sha256 = ("N" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=1004, name="v14")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipe: Dict[str, Any] = {
        "id": "r1",
        "loras": [
            {"isDeleted": True, "hash": sha256, "file_name": "old.safetensors"}
        ],
    }
    _set_recipe_cache(scanner, [recipe])

    saved, enriched = await _spy_rematch_persistence(scanner, monkeypatch)

    result = await scanner.rematch_recipe_by_id("r1")

    assert result["success"] is True
    assert result["recipe"] is enriched
    assert result["recipe"]["file_url"]
    assert saved == [recipe]


# ---------------------------------------------------------------------------
# rematch_all_recipes / rematch_recipes_bulk — bulk + progress (plan todo 3)
# ---------------------------------------------------------------------------


async def test_rematch_all_recipes_progress_sequence(tmp_path: Path, monkeypatch):
    sha256 = ("A" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=111, name="v1")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipes: list[Dict[str, Any]] = [
        {
            "id": "r1",
            "loras": [
                {"isDeleted": True, "hash": sha256, "file_name": "old.safetensors"}
            ],
        },
        {
            "id": "r2",
            "loras": [
                {"isDeleted": True, "hash": "zzz", "file_name": "gone.safetensors"}
            ],
        },
    ]
    _set_recipe_cache(scanner, recipes)
    await _spy_rematch_persistence(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    events: list[Dict[str, Any]] = []

    async def cb(ev: Dict[str, Any]) -> None:
        events.append(ev)

    result = await scanner.rematch_all_recipes(progress_callback=cb)

    assert [e["status"] for e in events] == [
        "started",
        "processing",
        "processing",
        "completed",
    ]
    assert events[1]["current"] == 1 and events[1]["total"] == 2
    assert events[2]["current"] == 2 and events[2]["total"] == 2
    assert events[3]["status"] == "completed"
    assert events[3]["rematched"] == 1
    assert events[3]["skipped"] == 1
    assert events[3]["errors"] == 0
    assert events[3]["total"] == 2
    assert events[3]["matched_recipes"] == 1
    assert events[3]["matched_entries"] == 1
    assert events[3]["unresolved_recipes"] == 1
    assert events[3]["unresolved_entries"] == 1

    assert result["success"] is True
    assert result["rematched"] == 1
    assert result["skipped"] == 1
    assert result["errors"] == 0
    assert result["total"] == 2
    assert result["matched_recipes"] == 1
    assert result["matched_entries"] == 1
    assert result["unresolved_recipes"] == 1
    assert result["unresolved_entries"] == 1
    assert "status" not in result
    assert resort_calls == [True]  # Metis F1 — exactly once per run


async def test_rematch_all_recipes_cancellation_stops_mid_loop(
    tmp_path: Path, monkeypatch
):
    sha256 = ("B" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=222, name="v2")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)

    recipes: list[Dict[str, Any]] = [
        {
            "id": f"r{i}",
            "loras": [
                {
                    "isDeleted": True,
                    "hash": sha256,
                    "file_name": f"old{i}.safetensors",
                }
            ],
        }
        for i in range(3)
    ]
    _set_recipe_cache(scanner, recipes)
    saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    events: list[Dict[str, Any]] = []
    cancelled = False

    async def cb(ev: Dict[str, Any]) -> None:
        nonlocal cancelled
        events.append(ev)
        if ev.get("status") == "processing" and ev.get("current") == 1:
            if not cancelled:
                cancelled = True
                scanner.cancel_task()

    result = await scanner.rematch_all_recipes(progress_callback=cb)

    assert result["status"] == "cancelled"
    assert result["success"] is False
    assert result["rematched"] == 1
    assert result["skipped"] == 0
    assert result["errors"] == 0
    assert result["total"] == 3

    # Only the first recipe was processed — the later ones are untouched.
    assert recipes[0]["loras"][0]["isDeleted"] is False
    assert recipes[1]["loras"][0]["isDeleted"] is True
    assert recipes[2]["loras"][0]["isDeleted"] is True
    assert len(saved) == 1
    assert [e["status"] for e in events][-1] == "cancelled"
    assert events[-1]["current"] == 1
    assert resort_calls == []  # cancelled run — no resort scheduled


async def test_rematch_all_recipes_per_recipe_error_continues_loop(
    tmp_path: Path, monkeypatch
):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    _set_recipe_cache(
        scanner,
        [{"id": "boom", "name": "Broken"}, {"id": "fine", "loras": []}],
    )
    await _spy_rematch_persistence(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    async def fake_single(
        recipe: Dict[str, Any],
        local_cache: dict[str, Any],
        autov3_cache: dict[str, Any],
        filename_cache=None,
    ) -> tuple[int, int, dict[str, Any]]:
        if recipe.get("id") == "boom":
            raise RuntimeError("kaboom")
        return (0, 0, {"matched": [], "unresolved": []})

    monkeypatch.setattr(scanner, "_rematch_single_recipe", fake_single)

    events: list[Dict[str, Any]] = []

    async def cb(ev: Dict[str, Any]) -> None:
        events.append(ev)

    result = await scanner.rematch_all_recipes(progress_callback=cb)

    assert result["success"] is True
    assert result["errors"] == 1
    assert result["rematched"] == 0
    assert result["skipped"] == 1
    assert result["total"] == 2
    # The loop continued past the failing recipe.
    assert [e["status"] for e in events] == [
        "started",
        "processing",
        "processing",
        "completed",
    ]
    assert resort_calls == [True]


async def test_rematch_all_recipes_schedule_resort_exactly_once(
    tmp_path: Path, monkeypatch
):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    _set_recipe_cache(scanner, [{"id": "a", "loras": []}, {"id": "b", "loras": []}])
    await _spy_rematch_persistence(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    await scanner.rematch_all_recipes()

    assert resort_calls == [True]


async def test_rematch_all_recipes_holds_mutation_lock(tmp_path: Path, monkeypatch):
    scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
    recipes = [{"id": f"r{i}", "loras": []} for i in range(5)]
    _set_recipe_cache(scanner, recipes)
    await _spy_rematch_persistence(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    entered = False
    release = asyncio.Event()
    original = scanner._rematch_single_recipe

    async def blocking_single(
        recipe: Dict[str, Any],
        local_cache: dict[str, Any],
        autov3_cache: dict[str, Any],
        filename_cache=None,
    ) -> tuple[int, int, dict[str, Any]]:
        nonlocal entered
        if recipe.get("id") == "r0":
            entered = True
            await release.wait()
        return await original(recipe, local_cache, autov3_cache, filename_cache)

    monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single)

    run_task = asyncio.create_task(scanner.rematch_all_recipes())
    for _ in range(100):
        if entered:
            break
        await asyncio.sleep(0.01)
    assert entered  # the run is now inside the lock

    acquired = asyncio.Event()

    async def probe() -> None:
        async with scanner._mutation_lock:
            acquired.set()

    probe_task = asyncio.create_task(probe())
    done, _ = await asyncio.wait([probe_task], timeout=0.1)
    assert not done  # mutation_lock is held by the run
    assert not acquired.is_set()

    release.set()
    await run_task
    await probe_task

    assert resort_calls == [True]


async def test_rematch_bulk_not_found_ids_skipped(tmp_path: Path, monkeypatch):
    sha256 = ("C" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=333, name="v3")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    recipes: list[Dict[str, Any]] = [
        {
            "id": f"r{i}",
            "loras": [
                {
                    "isDeleted": True,
                    "hash": sha256,
                    "file_name": f"old{i}.safetensors",
                }
            ],
        }
        for i in range(2)
    ]
    _set_recipe_cache(scanner, recipes)
    saved, enriched = await _spy_rematch_persistence(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    result = await scanner.rematch_recipes_bulk(["r0", "missing", "r1"])

    assert result["success"] is True
    assert result["total"] == 3
    assert result["rematched"] == 2  # r0 and r1 both matched
    assert result["skipped"] == 1  # missing id → RecipeNotFoundError → skipped
    assert result["errors"] == 0
    assert result["recipes"] == [enriched, enriched]
    assert saved == [recipes[0], recipes[1]]
    assert resort_calls == [True]


async def test_rematch_bulk_persist_failure_counted_once(tmp_path: Path, monkeypatch):
    sha256 = ("D" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=444, name="v4")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    recipe: Dict[str, Any] = {
        "id": "r1",
        "loras": [
            {"isDeleted": True, "hash": sha256, "file_name": "old.safetensors"}
        ],
    }
    _set_recipe_cache(scanner, [recipe])
    saved, _ = await _spy_rematch_persistence(scanner, monkeypatch, save_result=False)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    result = await scanner.rematch_recipes_bulk(["r1"])

    # Persist failure surfaces via the by_id summary's errors field and is NOT
    # additionally counted by the bulk loop (Oracle R2-F2 — no double count).
    assert result["errors"] == 1
    assert result["rematched"] == 0
    assert result["skipped"] == 0
    assert result["total"] == 1
    assert saved == [recipe]
    assert resort_calls == [True]


async def test_rematch_bulk_generic_exception_continues(tmp_path: Path, monkeypatch):
    sha256 = ("E" * 64).lower()
    item = _civitai_lora_item(sha256=sha256, version_id=555, name="v5")
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    recipes: list[Dict[str, Any]] = [
        {
            "id": f"r{i}",
            "loras": [
                {
                    "isDeleted": True,
                    "hash": sha256,
                    "file_name": f"old{i}.safetensors",
                }
            ],
        }
        for i in range(2)
    ]
    _set_recipe_cache(scanner, recipes)
    await _spy_rematch_persistence(scanner, monkeypatch)
    resort_calls = await _spy_resort(scanner, monkeypatch)

    # A generic exception inside matching escapes by_id (only
    # RecipePersistenceError is converted there) and is caught by the bulk
    # loop, which continues with the remaining ids (Oracle R4-F3).
    calls = 0

    async def fake_match(
        entry: Dict[str, Any],
        local_cache: dict[str, Any],
        autov3_cache: dict[str, Any],
        *,
        is_checkpoint: bool,
        filename_cache=None,
        recipe_base_model=None,
    ) -> Any:
        nonlocal calls
        calls += 1
        if calls == 1:
            raise RuntimeError("match boom")
        return (None, None)

    monkeypatch.setattr(scanner, "_match_rematch_entry_with_level", fake_match)

    result = await scanner.rematch_recipes_bulk(["r0", "r1"])

    assert result["success"] is True
    assert result["errors"] == 1
    assert result["rematched"] == 0
    assert result["skipped"] == 1  # r1 still processed after r0 blew up
    assert result["total"] == 2
    assert resort_calls == [True]


async def test_rematch_all_autov3_cache_reuse_across_calls(
    tmp_path: Path, monkeypatch
):
    from py.services import recipe_scanner as recipe_scanner_module

    called: list[str] = []

    def fake_calculate_autov3(file_path: str) -> str:
        called.append(file_path)
        return "AAAABBBBCCCD"

    monkeypatch.setattr(recipe_scanner_module, "calculate_autov3", fake_calculate_autov3)

    item = _rematch_item(
        sha256=("F" * 64).lower(), autov3=None, sub_type="lora"
    )
    scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
    recipe: Dict[str, Any] = {
        "id": "r1",
        "loras": [
            {"isDeleted": True, "hash": "AAAABBBBCCCD", "file_name": "old.safetensors"}
        ],
    }
    _set_recipe_cache(scanner, [recipe])
    await _spy_rematch_persistence(scanner, monkeypatch)

    first = await scanner.rematch_recipe_by_id("r1")
    second = await scanner.rematch_recipe_by_id("r1")

    assert first["success"] is True
    assert second["success"] is True
    # The version-cached autov3 snapshot is reused — the safetensors headers
    # are read once across both calls (Oracle R2-F4).
    assert len(called) == 1



async def test_find_all_duplicate_recipes_groups_by_fingerprint(recipe_scanner, monkeypatch):
    scanner, _ = recipe_scanner
    cache = SimpleNamespace(
        raw_data=[
            {"id": "r1", "fingerprint": "abc:0.8", "gen_params": {"prompt": "A Girl, blue hair"}},
            {"id": "r2", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
            {"id": "r3", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
            {"id": "r4", "fingerprint": "def:1.0", "gen_params": {"prompt": "A Girl, blue hair"}},
            {"id": "r5", "fingerprint": "", "gen_params": {"prompt": "landscape"}},
            {"id": "r6", "fingerprint": "", "gen_params": {}},
        ]
    )
    async def fake_get_cached_data():
        return cache
    monkeypatch.setattr(scanner, "get_cached_data", fake_get_cached_data)

    groups = await scanner.find_all_duplicate_recipes()
    assert groups == {"abc:0.8": ["r1", "r2", "r3"]}


async def test_find_all_duplicate_recipes_include_prompt_composite_key(recipe_scanner, monkeypatch):
    scanner, _ = recipe_scanner
    cache = SimpleNamespace(
        raw_data=[
            {"id": "r1", "fingerprint": "abc:0.8", "gen_params": {"prompt": "A Girl,   blue hair"}},
            {"id": "r2", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a girl, blue hair"}},
            {"id": "r3", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
            {"id": "r4", "fingerprint": "", "gen_params": {"prompt": "  landscape "}},
            {"id": "r5", "fingerprint": "", "gen_params": {"prompt": "landscape"}},
            {"id": "r6", "fingerprint": "", "gen_params": {}},
            {"id": "r7", "fingerprint": "def:1.0", "gen_params": {"prompt": "a girl, blue hair"}},
        ]
    )
    async def fake_get_cached_data():
        return cache
    monkeypatch.setattr(scanner, "get_cached_data", fake_get_cached_data)

    groups = await scanner.find_all_duplicate_recipes(include_prompt=True)
    assert groups == {
        "abc:0.8\x1fa girl, blue hair": ["r1", "r2"],
        "\x1flandscape": ["r4", "r5"],
    }
    # Same-lora recipes with different prompts are no longer duplicates
    assert "abc:0.8\x1fa boy" not in groups
    # Different-lora recipes with the same prompt are not grouped either
    assert "def:1.0\x1fa girl, blue hair" not in groups
    # Recipes with neither fingerprint nor prompt are skipped
    assert "r6" not in [rid for ids in groups.values() for rid in ids]


async def test_find_all_duplicate_recipes_include_prompt_missing_gen_params(recipe_scanner, monkeypatch):
    scanner, _ = recipe_scanner
    cache = SimpleNamespace(
        raw_data=[
            {"id": "r1", "fingerprint": "abc:0.8"},
            {"id": "r2", "fingerprint": "abc:0.8"},
            {"id": "r3", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
        ]
    )
    async def fake_get_cached_data():
        return cache
    monkeypatch.setattr(scanner, "get_cached_data", fake_get_cached_data)

    groups = await scanner.find_all_duplicate_recipes(include_prompt=True)
    # Recipes without gen_params/prompt normalize to empty prompt and match
    assert groups == {"abc:0.8\x1f": ["r1", "r2"]}
