import contextlib
import io
import json
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch

# Ensure `src/` layout is importable when running tests without installing the package.
REPO_ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = REPO_ROOT / "src"
sys.path.insert(0, str(SRC_DIR))


class TestCliSmoke(unittest.TestCase):
    def _make_hf_snapshot(self, root: Path, repo_id: str, *, snapshot_name: str = "abc123") -> Path:
        repo_dir = root / f"models--{repo_id.replace('/', '--')}"
        snap = repo_dir / "snapshots" / snapshot_name
        snap.mkdir(parents=True, exist_ok=True)
        (snap / "model_index.json").write_text("{}", encoding="utf-8")
        (repo_dir / "refs").mkdir(parents=True, exist_ok=True)
        (repo_dir / "refs" / "main").write_text(snapshot_name, encoding="utf-8")
        return snap

    def test_models_lists_known_id(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = main(["models"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("Qwen/Qwen-Image-2512", out)
        self.assertIn("black-forest-labs/FLUX.2-klein-4B", out)

    def test_tasks_lists_text_to_image(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = main(["tasks"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("text_to_image", out)

    def test_show_model_prints_tasks_section(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        runtime_specs = {
            "zai-org/GLM-Image": {
                "text_to_image": {
                    "supports_lora": True,
                    "lora_status": "validated",
                    "lora_target_roles": ["transformer"],
                    "lora_validation_profile": "glm_lora_validation",
                }
            }
        }
        with patch("abstractvision.cli._mlx_gen_runtime_task_specs", return_value=runtime_specs):
            with contextlib.redirect_stdout(buf):
                rc = main(["show-model", "zai-org/GLM-Image"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("zai-org/GLM-Image", out)
        self.assertIn("tasks:", out)
        self.assertIn("runtime routes:", out)
        self.assertIn("supports_lora: True", out)
        self.assertIn("lora_status: validated", out)
        self.assertIn("lora_target_roles: transformer", out)

    def test_provider_models_lists_openai_compatible_catalog(self):
        from abstractvision.cli import main

        class _Resp:
            headers = {}

            def read(self):
                return json.dumps({"data": [{"id": "provider/image-model"}]}).encode("utf-8")

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return False

        seen = {}

        def fake_urlopen(req, timeout=0):
            seen["url"] = req.full_url
            seen["method"] = req.get_method()
            return _Resp()

        buf = io.StringIO()
        with patch("abstractvision.backends.openai_compatible.urlopen", new=fake_urlopen):
            with contextlib.redirect_stdout(buf):
                rc = main(["provider-models", "--base-url", "http://localhost:1234/v1"])

        self.assertEqual(rc, 0)
        self.assertEqual(seen, {"url": "http://localhost:1234/v1/models", "method": "GET"})
        self.assertIn("provider/image-model", buf.getvalue())

    def test_provider_models_openai_uses_default_catalog(self):
        from abstractvision.cli import main

        class _Resp:
            headers = {}

            def read(self):
                return json.dumps(
                    {
                        "data": [
                            {"id": "gpt-4.1"},
                            {"id": "gpt-image-1"},
                            {"id": "dall-e-3"},
                        ]
                    }
                ).encode("utf-8")

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return False

        seen = {}

        def fake_urlopen(req, timeout=0):
            seen["url"] = req.full_url
            seen["method"] = req.get_method()
            seen["auth"] = req.headers.get("Authorization")
            return _Resp()

        buf = io.StringIO()
        with patch.dict("os.environ", {"OPENAI_API_KEY": "sk-test"}, clear=True):
            with patch("abstractvision.backends.openai_compatible.urlopen", new=fake_urlopen):
                with contextlib.redirect_stdout(buf):
                    rc = main(["provider-models", "--openai", "--task", "text_to_image"])

        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertEqual(
            seen,
            {
                "url": "https://api.openai.com/v1/models",
                "method": "GET",
                "auth": "Bearer sk-test",
            },
        )
        self.assertIn("gpt-image-1", out)
        self.assertIn("dall-e-3", out)
        self.assertNotIn("gpt-4.1", out)

    def test_repl_help_prioritizes_small_local_examples(self):
        from abstractvision.cli import _repl_help

        out = _repl_help()
        self.assertIn("runwayml/stable-diffusion-v1-5", out)
        self.assertIn("abstractvision model-presets", out)
        self.assertIn("cache-only by default", out)
        self.assertIn("/backend mlx-gen <preset_or_local_path> [base_model]", out)
        self.assertIn("black-forest-labs/FLUX.2-klein-4B", out)
        self.assertIn("/backend sdcpp <model_key|model.gguf|model.safetensors> [sd_cli_path]", out)
        self.assertIn("abstractvision download flux2-klein-base-4b --provider sdcpp", out)
        self.assertIn("/provider-models", out)
        self.assertIn("--negative-prompt", out)
        self.assertNotIn("FLUX.2-klein-9B", out)
        self.assertNotIn("--negative ...", out)

    def test_model_presets_lists_8bit_mlx_target(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = main(["model-presets", "--target", "mlx"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("target: mlx", out)
        self.assertIn("provider/engine: any", out)
        self.assertIn("mlx-gen", out)
        self.assertIn("AbstractFramework/flux.2-klein-4b-4bit", out)
        self.assertIn("AbstractFramework/flux.2-klein-4b-8bit", out)
        self.assertIn("AbstractFramework/qwen-image-2512-4bit", out)
        self.assertIn("AbstractFramework/z-image-turbo-4bit", out)
        self.assertIn("AbstractFramework/ernie-image-turbo-4bit", out)
        self.assertNotIn("mlx-community/Qwen-Image-2512-8bit", out)
        self.assertNotIn("argmaxinc/mlx-stable-diffusion-3-medium", out)

    def test_model_presets_can_filter_by_engine(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = main(["model-presets", "--target", "mlx", "--provider", "mlx-gen"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("provider/engine: mlx-gen", out)
        self.assertIn("AbstractFramework/flux.2-klein-4b-4bit", out)
        self.assertNotIn("stable-diffusion-3-medium", out)

    def test_upscale_command_defaults_to_seedvr2_mlx_gen(self):
        from abstractvision.cli import main

        seen = {}

        class _FakeManager:
            store = None
            backend = None

            def upscale_image(self, image, **kwargs):
                seen["image"] = image
                seen["kwargs"] = dict(kwargs)
                return {"ok": True}

        def fake_build(args):
            seen["provider"] = args.provider
            seen["model"] = args.model
            return _FakeManager()

        with tempfile.TemporaryDirectory() as td:
            img = Path(td) / "input.png"
            img.write_bytes(b"\x89PNG\r\n\x1a\n")
            with patch.dict("os.environ", {}, clear=True):
                with patch("abstractvision.cli._build_manager_from_args", new=fake_build):
                    with patch("abstractvision.cli._print_json", new=lambda _value: None):
                        rc = main(["upscale", "--image", str(img), "--no-progress"])

        self.assertEqual(rc, 0)
        self.assertEqual(seen["provider"], "mlx-gen")
        self.assertEqual(seen["model"], "AbstractFramework/seedvr2-3b-8bit")
        self.assertEqual(seen["image"], b"\x89PNG\r\n\x1a\n")
        self.assertEqual(seen["kwargs"]["resolution"], "2x")
        self.assertEqual(seen["kwargs"]["scale"], None)
        self.assertEqual(seen["kwargs"]["softness"], 0.25)

    def test_model_presets_accepts_legacy_mflux_engine_alias(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = main(["model-presets", "--target", "mlx", "--provider", "mflux"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("provider/engine: mlx-gen", out)

    def test_model_presets_lists_sdcpp_gguf_qwen_edit(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = main(["model-presets", "--provider", "sdcpp"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("provider/engine: stable-diffusion.cpp", out)
        self.assertIn("unsloth/Qwen-Image-Edit-2511-GGUF", out)
        self.assertEqual(out.count("unsloth/Qwen-Image-Edit-2511-GGUF"), 1)
        self.assertIn("gguf", out)

    def test_model_presets_rejects_generic_mlx_provider(self):
        from abstractvision.cli import main

        with self.assertRaisesRegex(SystemExit, "generic MLX image/video backend"):
            main(["model-presets", "--provider", "mlx"])

    def test_model_catalog_lists_quantized_and_fallback_entries(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = main(["catalog", "--all-targets"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("policy: lists exact published model ids", out)
        self.assertIn("AbstractFramework/flux.2-klein-4b-4bit", out)
        self.assertIn("AbstractFramework/flux.2-klein-4b-8bit", out)
        self.assertIn("prism-ml/bonsai-image-ternary-4B-mlx-2bit", out)
        self.assertIn("Qwen/Qwen-Image-2512", out)
        self.assertIn("Qwen/Qwen-Image-Edit-2511", out)
        self.assertIn("stable-diffusion-v1-5/stable-diffusion-v1-5", out)
        self.assertIn("baidu/ERNIE-Image", out)
        self.assertIn("stabilityai/stable-diffusion-xl-base-1.0", out)

    def test_model_catalog_task_filter_limits_rows(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = main(["model-catalog", "--task", "image_to_image", "--all-targets"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("black-forest-labs/FLUX.2-klein-4B", out)
        self.assertIn("stable-diffusion-v1-5/stable-diffusion-v1-5", out)
        self.assertNotIn("Tongyi-MAI/Z-Image-Turbo", out)
        self.assertNotIn("Qwen/Qwen-Image-2512", out)
        self.assertNotIn("zai-org/GLM-Image", out)

    def test_model_catalog_task_filter_lists_sdcpp_gguf_qwen_edit(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = main(
                ["model-catalog", "--task", "image_to_image", "--provider", "sdcpp", "--json"]
            )
        self.assertEqual(rc, 0)
        payload = json.loads(buf.getvalue())
        qwen = next(
            entry for entry in payload if entry.get("model_id") == "Qwen/Qwen-Image-Edit-2511"
        )
        matching = [
            item
            for item in qwen.get("downloads", [])
            if item.get("key") == "qwen-image-edit-2511-gguf"
            and item.get("target") == "gguf"
            and item.get("engine") == "stable-diffusion.cpp"
        ]
        self.assertEqual(len(matching), 1)
        self.assertIn("image_to_image", matching[0].get("tasks", []))

    def test_model_catalog_task_filter_lists_mlx_gen_i2i_rows(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with patch(
            "abstractvision.cli._mlx_gen_runtime_task_specs",
            return_value={
                "AbstractFramework/flux.2-klein-4b-4bit": {
                    "image_to_image": {
                        "supports_lora": True,
                        "lora_status": "mapped-unvalidated",
                        "lora_target_roles": ["transformer"],
                    }
                }
            },
        ):
            with contextlib.redirect_stdout(buf):
                rc = main(["catalog", "--task", "image_to_image", "--provider", "mlx-gen"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("AbstractFramework/flux.2-klein-4b-4bit", out)
        self.assertIn("AbstractFramework/flux.2-klein-9b-4bit", out)
        self.assertIn("mapped-unvalidated", out)
        self.assertIn("transformer", out)

    def test_model_catalog_json_is_parseable(self):
        from abstractvision.cli import main

        buf = io.StringIO()
        with patch(
            "abstractvision.cli._mlx_gen_runtime_task_specs",
            return_value={
                "Qwen/Qwen-Image-2512": {
                    "text_to_image": {
                        "supports_lora": True,
                        "lora_status": "validated",
                        "lora_target_roles": ["transformer"],
                        "lora_validation_profile": "qwen2512_pixel_art",
                    }
                }
            },
        ):
            with contextlib.redirect_stdout(buf):
                rc = main(["model-catalog", "--all-targets", "--json"])
        self.assertEqual(rc, 0)
        payload = json.loads(buf.getvalue())
        self.assertIsInstance(payload, list)
        qwen = next(entry for entry in payload if entry.get("model_id") == "Qwen/Qwen-Image-2512")
        self.assertIn("text_to_image", qwen.get("task_specs", {}))
        self.assertTrue(qwen["task_specs"]["text_to_image"]["supports_lora"])
        self.assertEqual(qwen["task_specs"]["text_to_image"]["lora_status"], "validated")

    def test_download_model_refuses_non_8bit_fallback_by_default(self):
        from abstractvision.cli import main

        with self.assertRaises(SystemExit) as ctx:
            main(["download", "stable-diffusion"])
        self.assertIn("No 8-bit preset", str(ctx.exception))

    def test_download_alias_diffusers_provider_implies_target_and_allows_full_snapshots_by_default(
        self,
    ):
        from abstractvision.cli import main

        calls = {}

        def fake_download(preset, *, model_dir=None, token=None, max_workers=4):
            calls["repo_id"] = preset.repo_id
            calls["target"] = preset.target
            calls["bits"] = preset.quantization_bits
            return (
                Path("/tmp/hf-cache")
                / f"models--{preset.repo_id.replace('/', '--')}"
                / "snapshots"
                / "abc123"
            )

        buf = io.StringIO()
        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            with contextlib.redirect_stdout(buf):
                rc = main(["download", "stable-diffusion", "--provider", "diffusers"])

        self.assertEqual(rc, 0)
        self.assertEqual(calls["repo_id"], "stable-diffusion-v1-5/stable-diffusion-v1-5")
        self.assertEqual(calls["target"], "diffusers")
        self.assertEqual(calls["bits"], 16)

    def test_download_diffusers_qwen_edit_prints_curated_16bit_note(self):
        from abstractvision.cli import main

        def fake_download(preset, *, model_dir=None, token=None, max_workers=4):
            return (
                Path("/tmp/hf-cache")
                / f"models--{preset.repo_id.replace('/', '--')}"
                / "snapshots"
                / "abc123"
            )

        buf = io.StringIO()
        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            with contextlib.redirect_stdout(buf):
                rc = main(["download", "qwen-image-edit-2511", "--provider", "diffusers"])

        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("Official curated Diffusers snapshot (16-bit).", out)
        self.assertNotIn("#FALLBACK: full Diffusers snapshot (not 8-bit).", out)

    def test_download_model_legacy_alias_still_works(self):
        from abstractvision.cli import main

        calls = {}

        def fake_download(preset, *, model_dir=None, token=None, max_workers=4):
            calls["repo_id"] = preset.repo_id
            return (
                Path("/tmp/hf-cache")
                / f"models--{preset.repo_id.replace('/', '--')}"
                / "snapshots"
                / "abc123"
            )

        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            rc = main(["download-model", "qwen-image", "--provider", "diffusers"])

        self.assertEqual(rc, 0)
        self.assertEqual(calls["repo_id"], "Qwen/Qwen-Image-2512")

    def test_diffusers_provider_uses_cached_snapshot_and_migrates_legacy_tree(self):
        from abstractvision.cli import _build_manager_from_args

        with tempfile.TemporaryDirectory() as legacy_td, tempfile.TemporaryDirectory() as cache_td:
            legacy_root = Path(legacy_td)
            legacy_dir = legacy_root / "qwen-image-2512-diffusers"
            legacy_dir.mkdir(parents=True, exist_ok=True)
            (legacy_dir / "model_index.json").write_text("{}", encoding="utf-8")

            with patch.dict(
                "os.environ",
                {"ABSTRACTVISION_MODEL_DIR": legacy_td, "HF_HUB_CACHE": cache_td},
                clear=True,
            ):
                args = types.SimpleNamespace(
                    store_dir=None,
                    provider="diffusers",
                    backend=None,
                    model="qwen-image",
                    model_id=None,
                    mflux_model=None,
                    mflux_base_model=None,
                    mflux_model_dir=None,
                    mflux_allow_download=False,
                    base_url=None,
                    api_key=None,
                    timeout_s=300.0,
                    models_path=None,
                    images_generations_path="/images/generations",
                    images_edits_path="/images/edits",
                    text_to_video_path=None,
                    image_to_video_path=None,
                    image_to_video_mode="multipart",
                    diffusers_device="cpu",
                    diffusers_torch_dtype=None,
                    diffusers_allow_download=False,
                    diffusers_auto_retry_fp32=True,
                    sdcpp_bin="sd-cli",
                    sdcpp_model=None,
                    sdcpp_diffusion_model=None,
                    sdcpp_vae=None,
                    sdcpp_llm=None,
                    sdcpp_llm_vision=None,
                    sdcpp_extra_args=None,
                    capabilities_model_id=None,
                )

                vm = _build_manager_from_args(args)
                backend = vm.backend
                cfg = getattr(backend, "_cfg", None)
                self.assertIsNotNone(cfg)
                model_id = str(getattr(cfg, "model_id", ""))
                self.assertEqual(model_id, "Qwen/Qwen-Image-2512")
                self.assertTrue((Path(cache_td) / "models--Qwen--Qwen-Image-2512").exists())
                self.assertFalse(legacy_dir.exists())

    def test_sdcpp_provider_resolves_cached_component_bundle_from_model_key(self):
        from abstractvision.cli import _build_manager_from_args
        from abstractvision.model_cache import import_directory_to_hf_cache

        with tempfile.TemporaryDirectory() as cache_td, tempfile.TemporaryDirectory() as src_td:
            cache_root = Path(cache_td)
            src_root = Path(src_td)

            main_dir = src_root / "flux-main"
            main_dir.mkdir(parents=True, exist_ok=True)
            (main_dir / "flux-2-klein-base-4b-Q8_0.gguf").write_bytes(b"GGUF")
            import_directory_to_hf_cache(
                main_dir,
                repo_id="leejet/FLUX.2-klein-base-4B-GGUF",
                cache_dir=str(cache_root),
                cleanup_source=False,
            )

            vae_dir = src_root / "flux-vae"
            (vae_dir / "vae").mkdir(parents=True, exist_ok=True)
            (vae_dir / "vae" / "diffusion_pytorch_model.safetensors").write_bytes(b"VAE")
            import_directory_to_hf_cache(
                vae_dir,
                repo_id="black-forest-labs/FLUX.2-klein-base-4B",
                cache_dir=str(cache_root),
                cleanup_source=False,
            )

            llm_dir = src_root / "qwen3"
            llm_dir.mkdir(parents=True, exist_ok=True)
            (llm_dir / "Qwen3-4B-Q4_K_M.gguf").write_bytes(b"GGUF")
            import_directory_to_hf_cache(
                llm_dir,
                repo_id="unsloth/Qwen3-4B-GGUF",
                cache_dir=str(cache_root),
                cleanup_source=False,
            )

            with patch.dict("os.environ", {"HF_HUB_CACHE": cache_td}, clear=True):
                args = types.SimpleNamespace(
                    store_dir=None,
                    provider="sdcpp",
                    backend=None,
                    model="flux2-klein-base-4b",
                    model_id=None,
                    mflux_model=None,
                    mflux_base_model=None,
                    mflux_model_dir=None,
                    mflux_allow_download=False,
                    base_url=None,
                    api_key=None,
                    timeout_s=300.0,
                    models_path=None,
                    images_generations_path="/images/generations",
                    images_edits_path="/images/edits",
                    text_to_video_path=None,
                    image_to_video_path=None,
                    image_to_video_mode="multipart",
                    diffusers_device="cpu",
                    diffusers_torch_dtype=None,
                    diffusers_allow_download=False,
                    diffusers_auto_retry_fp32=True,
                    sdcpp_bin="sd-cli",
                    sdcpp_model=None,
                    sdcpp_diffusion_model=None,
                    sdcpp_vae=None,
                    sdcpp_llm=None,
                    sdcpp_llm_vision=None,
                    sdcpp_extra_args=None,
                    capabilities_model_id=None,
                )

                vm = _build_manager_from_args(args)
                backend = vm.backend
                cfg = getattr(backend, "_cfg", None)
                self.assertIsNotNone(cfg)
                self.assertIsNone(getattr(cfg, "model", None))
                self.assertTrue(
                    str(getattr(cfg, "diffusion_model", "")).endswith(
                        "flux-2-klein-base-4b-Q8_0.gguf"
                    )
                )
                self.assertTrue(
                    str(getattr(cfg, "vae", "")).endswith("vae/diffusion_pytorch_model.safetensors")
                )
                self.assertTrue(str(getattr(cfg, "llm", "")).endswith("Qwen3-4B-Q4_K_M.gguf"))

    def test_download_model_selects_default_mlx_gen_q4_without_network_when_mocked(self):
        from abstractvision.cli import main

        calls = {}

        def fake_download(preset, *, model_dir=None, token=None, max_workers=4):
            calls["repo_id"] = preset.repo_id
            calls["model_dir"] = model_dir
            calls["max_workers"] = max_workers
            return (
                Path("/tmp/hf-cache")
                / f"models--{preset.repo_id.replace('/', '--')}"
                / "snapshots"
                / "abc123"
            )

        buf = io.StringIO()
        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            with contextlib.redirect_stdout(buf):
                rc = main(
                    [
                        "download-model",
                        "AbstractFramework/flux.2-klein-9b-4bit",
                        "--provider",
                        "mlx-gen",
                        "--model-dir",
                        "/tmp/legacy-models",
                        "--max-workers",
                        "2",
                    ]
                )
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertEqual(calls["repo_id"], "AbstractFramework/flux.2-klein-9b-4bit")
        self.assertEqual(str(calls["model_dir"]), "/tmp/legacy-models")
        self.assertEqual(calls["max_workers"], 2)
        self.assertIn(
            "/tmp/hf-cache/models--AbstractFramework--flux.2-klein-9b-4bit/snapshots/abc123", out
        )

    def test_download_model_selects_exact_mlx_gen_q8_repo_without_network_when_mocked(self):
        from abstractvision.cli import main

        calls = {}

        def fake_download(preset, *, model_dir=None, token=None, max_workers=4):
            calls["repo_id"] = preset.repo_id
            calls["bits"] = preset.quantization_bits
            return (
                Path("/tmp/hf-cache")
                / f"models--{preset.repo_id.replace('/', '--')}"
                / "snapshots"
                / "abc123"
            )

        buf = io.StringIO()
        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            with contextlib.redirect_stdout(buf):
                rc = main(
                    [
                        "download-model",
                        "AbstractFramework/flux.2-klein-9b-8bit",
                        "--provider",
                        "mlx-gen",
                    ]
                )

        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertEqual(calls["repo_id"], "AbstractFramework/flux.2-klein-9b-8bit")
        self.assertEqual(calls["bits"], 8)
        self.assertIn("bits: 8", out)

    def test_download_model_selects_exact_ernie_mlx_gen_q4_without_network_when_mocked(self):
        from abstractvision.cli import main

        calls = {}

        def fake_download(preset, *, model_dir=None, token=None, max_workers=4):
            calls["repo_id"] = preset.repo_id
            calls["bits"] = preset.quantization_bits
            return (
                Path("/tmp/hf-cache")
                / f"models--{preset.repo_id.replace('/', '--')}"
                / "snapshots"
                / "abc123"
            )

        buf = io.StringIO()
        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            with contextlib.redirect_stdout(buf):
                rc = main(
                    [
                        "download",
                        "AbstractFramework/ernie-image-turbo-4bit",
                        "--provider",
                        "mlx-gen",
                    ]
                )

        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertEqual(calls["repo_id"], "AbstractFramework/ernie-image-turbo-4bit")
        self.assertEqual(calls["bits"], 4)
        self.assertIn("bits: 4", out)

    def test_download_model_selects_exact_ernie_mlx_gen_q8_without_network_when_mocked(self):
        from abstractvision.cli import main

        calls = {}

        def fake_download(preset, *, model_dir=None, token=None, max_workers=4):
            calls["repo_id"] = preset.repo_id
            calls["bits"] = preset.quantization_bits
            return (
                Path("/tmp/hf-cache")
                / f"models--{preset.repo_id.replace('/', '--')}"
                / "snapshots"
                / "abc123"
            )

        buf = io.StringIO()
        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            with contextlib.redirect_stdout(buf):
                rc = main(
                    [
                        "download",
                        "AbstractFramework/ernie-image-turbo-8bit",
                        "--provider",
                        "mlx-gen",
                    ]
                )

        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertEqual(calls["repo_id"], "AbstractFramework/ernie-image-turbo-8bit")
        self.assertEqual(calls["bits"], 8)
        self.assertIn("bits: 8", out)

    def test_download_model_accepts_repo_id_fallback(self):
        from abstractvision.cli import main

        calls = {}

        def fake_snapshot(
            repo_id,
            *,
            token=None,
            revision=None,
            allow_patterns=None,
            ignore_patterns=None,
            cache_dir=None,
            local_files_only=False,
            max_workers=4,
        ):
            calls["repo_id"] = repo_id
            calls["cache_dir"] = cache_dir
            calls["local_files_only"] = local_files_only
            calls["max_workers"] = max_workers
            return Path("/tmp/hf-cache") / "snap"

        buf = io.StringIO()
        with patch.dict("os.environ", {"HF_HUB_CACHE": "/tmp/hf-cache"}, clear=True):
            with patch("abstractvision.cli.download_hf_repo_snapshot", new=fake_snapshot):
                with contextlib.redirect_stdout(buf):
                    rc = main(["download-model", "some-org/some-model", "--max-workers", "2"])

        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertEqual(calls["repo_id"], "some-org/some-model")
        self.assertEqual(calls["cache_dir"], "/tmp/hf-cache")
        self.assertFalse(calls["local_files_only"])
        self.assertEqual(calls["max_workers"], 2)
        self.assertIn("/tmp/hf-cache/snap", out)

    def test_download_model_uses_curated_preset_when_repo_id_is_known(self):
        from abstractvision.cli import main

        calls = {}

        def fake_download(preset, *, model_dir=None, token=None, max_workers=4):
            calls["repo_id"] = preset.repo_id
            calls["local_dir_name"] = preset.local_dir_name
            calls["max_workers"] = max_workers
            return (
                Path("/tmp/hf-cache")
                / f"models--{preset.repo_id.replace('/', '--')}"
                / "snapshots"
                / "abc123"
            )

        buf = io.StringIO()
        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            with contextlib.redirect_stdout(buf):
                rc = main(
                    [
                        "download-model",
                        "AbstractFramework/qwen-image-2512-8bit",
                        "--max-workers",
                        "2",
                    ]
                )

        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertEqual(calls["repo_id"], "AbstractFramework/qwen-image-2512-8bit")
        self.assertEqual(calls["local_dir_name"], "qwen-image-2512-mlx-gen-8bit")
        self.assertEqual(calls["max_workers"], 2)
        self.assertIn(
            "/tmp/hf-cache/models--AbstractFramework--qwen-image-2512-8bit/snapshots/abc123", out
        )

    def test_download_model_sdcpp_prints_resolved_bundle_paths(self):
        from abstractvision.cli import main
        from abstractvision.model_downloads import SdcppModelSelection

        def fake_download(preset, *, model_dir=None, token=None, max_workers=4):
            return (
                Path("/tmp/hf-cache")
                / f"models--{preset.repo_id.replace('/', '--')}"
                / "snapshots"
                / "abc123"
            )

        def fake_resolve(name, *, allow_download=False, token=None, max_workers=4):
            self.assertEqual(name, "flux2-klein-base-4b")
            self.assertTrue(allow_download)
            return SdcppModelSelection(
                key="flux2-klein-base-4b",
                repo_id="leejet/FLUX.2-klein-base-4B-GGUF",
                diffusion_model="/tmp/hf-cache/flux-2-klein-base-4b-Q8_0.gguf",
                vae="/tmp/hf-cache/vae/diffusion_pytorch_model.safetensors",
                llm="/tmp/hf-cache/Qwen3-4B-Q4_K_M.gguf",
            )

        buf = io.StringIO()
        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            with patch("abstractvision.cli.resolve_sdcpp_model_selection", new=fake_resolve):
                with patch(
                    "abstractvision.backends.stable_diffusion_cpp._require_sd_cli",
                    return_value="/usr/bin/sd-cli",
                ):
                    with contextlib.redirect_stdout(buf):
                        rc = main(["download-model", "flux2-klein-base-4b", "--provider", "sdcpp"])

        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn("resolved_diffusion_model: /tmp/hf-cache/flux-2-klein-base-4b-Q8_0.gguf", out)
        self.assertIn("resolved_vae: /tmp/hf-cache/vae/diffusion_pytorch_model.safetensors", out)
        self.assertIn("resolved_llm: /tmp/hf-cache/Qwen3-4B-Q4_K_M.gguf", out)

    def test_download_model_rejects_generic_mlx_engine_prefix(self):
        from abstractvision.cli import main

        with self.assertRaisesRegex(SystemExit, "generic MLX image/video backend"):
            main(["download-model", "mlx", "flux2-klein-4b"])

    def test_download_model_supports_registry_only_hf_snapshot_preset(self):
        from abstractvision.cli import main

        calls = {}

        def fake_download(preset, *, model_dir=None, token=None, max_workers=4):
            calls["repo_id"] = preset.repo_id
            calls["target"] = preset.target
            calls["engine"] = preset.engine
            calls["bits"] = preset.quantization_bits
            return (
                Path("/tmp/hf-cache") / "models--tencent--HunyuanImage-3.0" / "snapshots" / "abc123"
            )

        buf = io.StringIO()
        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            with contextlib.redirect_stdout(buf):
                rc = main(["download-model", "hunyuanimage-3.0"])

        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertEqual(calls["repo_id"], "tencent/HunyuanImage-3.0")
        self.assertEqual(calls["target"], "hf-snapshot")
        self.assertEqual(calls["engine"], "transformers")
        self.assertEqual(calls["bits"], 16)
        self.assertIn("/tmp/hf-cache/models--tencent--HunyuanImage-3.0/snapshots/abc123", out)

    def test_download_model_surfaces_friendly_hf_access_message(self):
        from abstractvision.cli import main
        from abstractvision.model_downloads import HuggingFaceAccessError

        def fake_download(_preset, *, model_dir=None, token=None, max_workers=4):
            raise HuggingFaceAccessError(
                "stabilityai/stable-diffusion-3.5-medium",
                raw_message="403 Client Error: gated repo",
                status_code=403,
            )

        buf = io.StringIO()
        err = io.StringIO()
        with patch("abstractvision.cli.download_model_preset", new=fake_download):
            with patch("sys.stdin.isatty", return_value=False):
                with contextlib.redirect_stdout(buf):
                    with contextlib.redirect_stderr(err):
                        with self.assertRaises(SystemExit) as ctx:
                            main(["download-model", "sd3.5-medium", "--provider", "diffusers"])

        msg = str(ctx.exception)
        self.assertIn("https://huggingface.co/stabilityai/stable-diffusion-3.5-medium", msg)
        self.assertIn("https://huggingface.co/settings/tokens", msg)
        self.assertIn("hf auth login", msg)

    def test_download_model_can_prompt_for_hf_token_and_retry(self):
        from abstractvision.cli import main
        from abstractvision.model_downloads import HuggingFaceAccessError

        calls = {"count": 0, "tokens": []}

        def fake_download(_preset, *, model_dir=None, token=None, max_workers=4):
            calls["count"] += 1
            calls["tokens"].append(token)
            if calls["count"] == 1:
                raise HuggingFaceAccessError(
                    "stabilityai/stable-diffusion-3.5-medium",
                    raw_message="403 Client Error: gated repo",
                    status_code=403,
                )
            return Path(
                "/tmp/hf-cache/models--stabilityai--stable-diffusion-3.5-medium/snapshots/abc123"
            )

        buf = io.StringIO()
        err = io.StringIO()
        with patch.dict("os.environ", {}, clear=True):
            with patch("abstractvision.cli.download_model_preset", new=fake_download):
                with patch("sys.stdin.isatty", return_value=True):
                    with patch("abstractvision.cli.getpass.getpass", return_value="hf_test_token"):
                        with contextlib.redirect_stdout(buf):
                            with contextlib.redirect_stderr(err):
                                rc = main(
                                    ["download-model", "sd3.5-medium", "--provider", "diffusers"]
                                )

        self.assertEqual(rc, 0)
        self.assertEqual(calls["tokens"], [None, "hf_test_token"])
        self.assertIn(
            "/tmp/hf-cache/models--stabilityai--stable-diffusion-3.5-medium/snapshots/abc123",
            buf.getvalue(),
        )

    def test_repl_state_starts_unconfigured_without_backend_env(self):
        from abstractvision.cli import DEFAULT_DIFFUSERS_DEVICE, _ReplState

        with patch.dict("os.environ", {}, clear=True):
            state = _ReplState()

        self.assertEqual(state.backend_kind, "")
        self.assertIsNone(state.model_id)
        self.assertEqual(state.diffusers_device, DEFAULT_DIFFUSERS_DEVICE)
        self.assertFalse(state.diffusers_allow_download)
        self.assertIsNone(state.defaults["t2i"]["width"])
        self.assertIsNone(state.defaults["t2i"]["height"])
        self.assertIsNone(state.defaults["t2i"]["steps"])
        self.assertIsNone(state.defaults["i2i"]["steps"])
        self.assertIn("t2v", state.defaults)
        self.assertIsNone(state.defaults["t2v"]["width"])
        self.assertIsNone(state.defaults["t2v"]["fps"])
        self.assertIsNone(state.defaults["t2v"]["steps"])
        self.assertIsNone(state.defaults["t2v"]["guidance_2"])
        self.assertIn("i2v", state.defaults)
        self.assertIsNone(state.defaults["i2v"]["width"])
        self.assertIsNone(state.defaults["i2v"]["fps"])
        self.assertIsNone(state.defaults["i2v"]["steps"])
        self.assertIsNone(state.defaults["i2v"]["guidance_2"])

    def test_repl_state_defaults_to_openai_when_base_url_is_configured(self):
        from abstractvision.cli import _ReplState

        with patch.dict("os.environ", {"OPENAI_BASE_URL": "http://localhost:1234/v1"}, clear=True):
            state = _ReplState()

        self.assertEqual(state.backend_kind, "openai")
        self.assertEqual(state.base_url, "http://localhost:1234/v1")
        self.assertIsNone(state.model_id)

    def test_repl_state_openai_override_does_not_inherit_diffusers_model(self):
        from abstractvision.cli import _ReplState

        with patch.dict("os.environ", {"ABSTRACTVISION_BACKEND": "openai"}, clear=True):
            state = _ReplState()

        self.assertEqual(state.backend_kind, "openai")
        self.assertIsNone(state.model_id)

    def test_repl_accepts_sdcpp_single_model_backend(self):
        from abstractvision.cli import main

        commands = iter(
            [
                "/backend sdcpp /models/sd-v1-5.gguf /opt/sd-cli",
                "/config",
                "/exit",
            ]
        )
        buf = io.StringIO()
        with patch("builtins.input", side_effect=lambda _prompt: next(commands)):
            with contextlib.redirect_stdout(buf):
                rc = main(["cli"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn('"backend_kind": "sdcpp"', out)
        self.assertIn('"sdcpp_bin": "/opt/sd-cli"', out)
        self.assertIn('"sdcpp_model": "/models/sd-v1-5.gguf"', out)
        self.assertIn('"sdcpp_diffusion_model": null', out)
        self.assertIn('"model_id": null', out)

    def test_repl_accepts_mlx_gen_backend(self):
        from abstractvision.cli import main

        commands = iter(
            [
                "/backend mlx-gen AbstractFramework/flux.2-klein-4b-4bit",
                "/config",
                "/exit",
            ]
        )
        buf = io.StringIO()
        with patch("builtins.input", side_effect=lambda _prompt: next(commands)):
            with contextlib.redirect_stdout(buf):
                rc = main(["cli"])
        out = buf.getvalue()
        self.assertEqual(rc, 0)
        self.assertIn('"backend_kind": "mlx-gen"', out)
        self.assertIn('"mflux_model": "AbstractFramework/flux.2-klein-4b-4bit"', out)

    def test_cli_has_t2v_command(self):
        from abstractvision.cli import build_parser

        parser = build_parser()
        args = parser.parse_args(
            ["t2v", "hello", "--frames", "9", "--fps", "8", "--max-sequence-length", "256"]
        )
        self.assertEqual(args.cmd, "t2v")
        self.assertEqual(args.prompt, "hello")
        self.assertEqual(args.num_frames, 9)
        self.assertEqual(args.fps, 8)
        self.assertEqual(args.max_sequence_length, 256)
        self.assertIsNone(args.steps)
        self.assertIsNone(args.guidance_2)
        self.assertTrue(args.progress)
        self.assertTrue(callable(args._fn))

        no_progress = parser.parse_args(["t2v", "hello", "--no-progress"])
        self.assertFalse(no_progress.progress)

    def test_cli_has_i2v_command(self):
        from abstractvision.cli import build_parser

        parser = build_parser()
        args = parser.parse_args(
            [
                "i2v",
                "--provider",
                "mlx-gen",
                "--model",
                "Wan-AI/Wan2.2-TI2V-5B-Diffusers",
                "--image",
                "first.png",
                "move slowly",
                "--frames",
                "9",
                "--fps",
                "8",
                "--max-sequence-length",
                "256",
            ]
        )
        self.assertEqual(args.cmd, "i2v")
        self.assertEqual(args.provider, "mlx-gen")
        self.assertEqual(args.model, "Wan-AI/Wan2.2-TI2V-5B-Diffusers")
        self.assertEqual(args.image, "first.png")
        self.assertEqual(args.prompt, "move slowly")
        self.assertEqual(args.num_frames, 9)
        self.assertEqual(args.fps, 8)
        self.assertEqual(args.max_sequence_length, 256)
        self.assertTrue(args.progress)
        self.assertTrue(callable(args._fn))

    def test_cli_video_progress_renders_frame_and_step_status(self):
        from abstractvision.cli import _CliVideoProgress
        from abstractvision.types import VideoProgressEvent

        err = io.StringIO()
        progress = _CliVideoProgress(stream=err)
        progress(
            VideoProgressEvent(
                phase="denoise",
                frame=3,
                total_frames=10,
                step=2,
                total_steps=5,
                progress=0.4,
                frame_progress=0.3,
            )
        )
        progress.close()

        out = err.getvalue()
        self.assertIn("Generating video: 2/5 steps", out)
        self.assertIn("40.0%", out)
        self.assertIn("denoise frame 3/10", out)

    def test_cli_image_progress_renders_step_status(self):
        from abstractvision.cli import _CliVideoProgress
        from abstractvision.types import VideoProgressEvent

        err = io.StringIO()
        progress = _CliVideoProgress(stream=err)
        progress(
            VideoProgressEvent(
                phase="denoise",
                step=2,
                total_steps=5,
                progress=0.4,
                task="text_to_image",
            )
        )
        progress.close()

        out = err.getvalue()
        self.assertIn("Generating image: 2/5 steps", out)
        self.assertIn("40.0%", out)

    def test_cli_i2i_parser_default_steps_are_none(self):
        from abstractvision.cli import build_parser

        parser = build_parser()
        args = parser.parse_args(["i2i", "--image", "input.png", "hello"])

        self.assertIsNone(args.steps)
        self.assertFalse(args.progress)
        self.assertEqual(args.reference_images, [])

    def test_cli_i2i_parser_accepts_reference_images_and_progress(self):
        from abstractvision.cli import build_parser

        parser = build_parser()
        args = parser.parse_args(
            [
                "i2i",
                "--image",
                "input.png",
                "--reference-image",
                "style.png",
                "--reference-image",
                "layout.png",
                "--progress",
                "combine them",
            ]
        )

        self.assertTrue(args.progress)
        self.assertEqual(args.reference_images, ["style.png", "layout.png"])

    def test_cli_i2i_command_passes_reference_images_and_progress(self):
        from abstractvision.cli import _cmd_i2i, build_parser

        seen = {}

        class FakeVisionManager:
            backend = None
            store = None

            def edit_image(self, prompt, **kwargs):
                seen["prompt"] = prompt
                seen["kwargs"] = kwargs
                return {"ok": True}

        with tempfile.TemporaryDirectory() as td:
            input_path = Path(td) / "input.png"
            ref_path = Path(td) / "style.png"
            input_path.write_bytes(b"input-image")
            ref_path.write_bytes(b"reference-image")

            parser = build_parser()
            args = parser.parse_args(
                [
                    "i2i",
                    "--image",
                    str(input_path),
                    "--reference-image",
                    str(ref_path),
                    "--strength",
                    "0.4",
                    "--progress",
                    "combine references",
                ]
            )

            with patch(
                "abstractvision.cli._build_manager_from_args",
                return_value=FakeVisionManager(),
            ):
                with contextlib.redirect_stdout(io.StringIO()):
                    rc = _cmd_i2i(args)

        self.assertEqual(rc, 0)
        self.assertEqual(seen["prompt"], "combine references")
        self.assertEqual(seen["kwargs"]["image"], b"input-image")
        self.assertEqual(seen["kwargs"]["extra"]["reference_images"], [b"reference-image"])
        self.assertEqual(seen["kwargs"]["extra"]["strength"], 0.4)
        self.assertIn("on_progress", seen["kwargs"]["extra"])

    def test_cli_t2i_parser_defaults_are_none(self):
        from abstractvision.cli import build_parser

        parser = build_parser()
        args = parser.parse_args(["t2i", "hello"])

        self.assertIsNone(args.width)
        self.assertIsNone(args.height)
        self.assertIsNone(args.steps)
        self.assertFalse(args.progress)

    def test_cli_t2i_parser_accepts_lora_flags(self):
        from abstractvision.cli import build_parser

        parser = build_parser()
        args = parser.parse_args(
            [
                "t2i",
                "--lora",
                "owner/style-lora:style.safetensors",
                "--lora-scale",
                "0.75",
                "--lora-adapter-name",
                "style",
                "hello",
            ]
        )

        self.assertEqual(args.lora_paths, ["owner/style-lora:style.safetensors"])
        self.assertEqual(args.lora_scales, [0.75])
        self.assertEqual(args.lora_adapter_name, ["style"])

    def test_cli_t2i_parser_accepts_batch_seed_flags(self):
        from abstractvision.cli import build_parser

        parser = build_parser()
        args = parser.parse_args(
            [
                "t2i",
                "--count",
                "3",
                "--seeds",
                "11,12",
                "--seeds",
                "13",
                "hello",
            ]
        )

        self.assertEqual(args.count, 3)
        self.assertEqual(args.seeds, ["11,12", "13"])

    def test_t2i_provider_mflux_alias_routes_to_mlx_gen_backend(self):
        from abstractvision.backends.mflux import MFluxVisionBackend
        from abstractvision.cli import _build_manager_from_args, build_parser

        parser = build_parser()
        args = parser.parse_args(
            [
                "t2i",
                "--provider",
                "mflux",
                "--model",
                "AbstractFramework/qwen-image-2512-4bit",
                "hello",
            ]
        )

        vm = _build_manager_from_args(args)

        self.assertIsInstance(vm.backend, MFluxVisionBackend)
        self.assertEqual(
            getattr(vm.backend, "_cfg").model, "AbstractFramework/qwen-image-2512-4bit"
        )

    def test_t2i_mlx_gen_exact_q8_model_id_is_threaded_to_backend_config(self):
        from abstractvision.backends.mflux import MFluxVisionBackend
        from abstractvision.cli import _build_manager_from_args, build_parser

        parser = build_parser()
        args = parser.parse_args(
            [
                "t2i",
                "--provider",
                "mlx-gen",
                "--model",
                "AbstractFramework/qwen-image-2512-8bit",
                "hello",
            ]
        )

        vm = _build_manager_from_args(args)

        self.assertIsInstance(vm.backend, MFluxVisionBackend)
        self.assertEqual(
            getattr(vm.backend, "_cfg").model, "AbstractFramework/qwen-image-2512-8bit"
        )

    def test_t2i_model_prefix_mflux_alias_routes_to_mlx_gen_backend(self):
        from abstractvision.backends.mflux import MFluxVisionBackend
        from abstractvision.cli import _build_manager_from_args, build_parser

        parser = build_parser()
        args = parser.parse_args(
            ["t2i", "--model", "mflux/AbstractFramework/qwen-image-2512-4bit", "hello"]
        )

        vm = _build_manager_from_args(args)

        self.assertIsInstance(vm.backend, MFluxVisionBackend)
        self.assertEqual(
            getattr(vm.backend, "_cfg").model, "AbstractFramework/qwen-image-2512-4bit"
        )

    def test_i2i_provider_mflux_alias_routes_to_mlx_gen_backend(self):
        from abstractvision.backends.mflux import MFluxVisionBackend
        from abstractvision.cli import _build_manager_from_args, build_parser

        parser = build_parser()
        args = parser.parse_args(
            [
                "i2i",
                "--provider",
                "mflux",
                "--model",
                "AbstractFramework/qwen-image-edit-2511-4bit",
                "--image",
                "input.png",
                "hello",
            ]
        )

        vm = _build_manager_from_args(args)

        self.assertIsInstance(vm.backend, MFluxVisionBackend)
        self.assertEqual(
            getattr(vm.backend, "_cfg").model, "AbstractFramework/qwen-image-edit-2511-4bit"
        )

    def test_t2v_provider_mlx_gen_routes_to_mlx_gen_backend(self):
        from abstractvision.backends.mflux import MFluxVisionBackend
        from abstractvision.cli import _build_manager_from_args, build_parser

        parser = build_parser()
        args = parser.parse_args(
            [
                "t2v",
                "--provider",
                "mlx-gen",
                "--model",
                "Wan-AI/Wan2.2-TI2V-5B-Diffusers",
                "hello",
            ]
        )

        vm = _build_manager_from_args(args)

        self.assertIsInstance(vm.backend, MFluxVisionBackend)
        self.assertEqual(getattr(vm.backend, "_cfg").model, "Wan-AI/Wan2.2-TI2V-5B-Diffusers")

    def test_cli_t2v_parser_accepts_lora_target_roles(self):
        from abstractvision.cli import build_parser

        parser = build_parser()
        args = parser.parse_args(
            [
                "t2v",
                "--lora",
                "owner/wan-lora:video.safetensors",
                "--lora-scale",
                "0.9",
                "--lora-target-role",
                "high_noise_transformer",
                "hello",
            ]
        )

        self.assertEqual(args.lora_paths, ["owner/wan-lora:video.safetensors"])
        self.assertEqual(args.lora_scales, [0.9])
        self.assertEqual(args.lora_target_roles, ["high_noise_transformer"])

    def test_cli_t2v_parser_accepts_flow_shift_and_batch_seed_flags(self):
        from abstractvision.cli import build_parser

        parser = build_parser()
        args = parser.parse_args(
            [
                "t2v",
                "--flow-shift",
                "3",
                "--count",
                "2",
                "--seeds",
                "91",
                "--seeds",
                "92",
                "hello",
            ]
        )

        self.assertEqual(args.flow_shift, 3.0)
        self.assertEqual(args.count, 2)
        self.assertEqual(args.seeds, ["91", "92"])

    def test_provider_adapters_command_prints_json_inventory(self):
        from abstractvision.cli import main

        class FakeBackend:
            def list_provider_adapters(self, *, model=None, task=None):
                self.model = model
                self.task = task
                from abstractvision.types import ProviderAdapterInfo

                return [
                    ProviderAdapterInfo(
                        id="owner/example:adapter.safetensors",
                        repo_id="owner/example",
                        compatible_models=("AbstractFramework/qwen-image-2512-8bit",),
                        compatible_tasks=("text_to_image",),
                        suggested_target_roles=("transformer",),
                    )
                ]

        buf = io.StringIO()
        with patch("abstractvision.cli.MFluxVisionBackend", return_value=FakeBackend()):
            with contextlib.redirect_stdout(buf):
                rc = main(
                    [
                        "adapters",
                        "--model",
                        "AbstractFramework/qwen-image-2512-8bit",
                        "--task",
                        "text_to_image",
                        "--json",
                    ]
                )

        payload = json.loads(buf.getvalue())
        self.assertEqual(rc, 0)
        self.assertEqual(payload[0]["id"], "owner/example:adapter.safetensors")
        self.assertEqual(
            payload[0]["compatible_models"],
            ["AbstractFramework/qwen-image-2512-8bit"],
        )

    def test_resolve_i2i_steps_prefers_backend_recommended_default(self):
        from dataclasses import replace

        from abstractvision.cli import _resolve_i2i_steps

        class FakeBackend:
            def normalize_image_edit_request(self, request):
                self.seen = request
                return replace(request, steps=28)

        class FakeVm:
            def __init__(self):
                self.backend = FakeBackend()

        steps = _resolve_i2i_steps(
            FakeVm(),
            prompt="hello",
            image=b"img",
            mask=None,
            negative_prompt=None,
            guidance_scale=None,
            seed=None,
            requested_steps=None,
        )

        self.assertEqual(steps, 28)

    def test_resolve_i2i_steps_falls_back_to_15_without_model_default(self):
        from abstractvision.cli import DEFAULT_I2I_STEPS, _resolve_i2i_steps

        class FakeBackend:
            pass

        class FakeVm:
            def __init__(self):
                self.backend = FakeBackend()

        steps = _resolve_i2i_steps(
            FakeVm(),
            prompt="hello",
            image=b"img",
            mask=None,
            negative_prompt=None,
            guidance_scale=None,
            seed=None,
            requested_steps=None,
        )

        self.assertEqual(steps, DEFAULT_I2I_STEPS)

    def test_resolve_t2i_request_prefers_backend_recommended_defaults(self):
        from dataclasses import replace

        from abstractvision.cli import _resolve_t2i_request

        class FakeBackend:
            def normalize_image_generation_request(self, request):
                return replace(request, width=1024, height=768, steps=28, guidance_scale=3.5)

        class FakeVm:
            def __init__(self):
                self.backend = FakeBackend()

        request = _resolve_t2i_request(
            FakeVm(),
            prompt="hello",
            negative_prompt=None,
            width=None,
            height=None,
            steps=None,
            guidance_scale=None,
            seed=None,
        )

        self.assertEqual(request.width, 1024)
        self.assertEqual(request.height, 768)
        self.assertEqual(request.steps, 28)
        self.assertEqual(request.guidance_scale, 3.5)

    def test_resolve_t2i_request_falls_back_to_package_defaults(self):
        from abstractvision.cli import (
            DEFAULT_T2I_HEIGHT,
            DEFAULT_T2I_STEPS,
            DEFAULT_T2I_WIDTH,
            _resolve_t2i_request,
        )

        class FakeBackend:
            pass

        class FakeVm:
            def __init__(self):
                self.backend = FakeBackend()

        request = _resolve_t2i_request(
            FakeVm(),
            prompt="hello",
            negative_prompt=None,
            width=None,
            height=None,
            steps=None,
            guidance_scale=None,
            seed=None,
        )

        self.assertEqual(request.width, DEFAULT_T2I_WIDTH)
        self.assertEqual(request.height, DEFAULT_T2I_HEIGHT)
        self.assertEqual(request.steps, DEFAULT_T2I_STEPS)

    def test_repl_build_manager_resolves_cached_sdcpp_component_bundle_from_model_key(self):
        from abstractvision.cli import _ReplState, _build_manager_from_state
        from abstractvision.model_cache import import_directory_to_hf_cache

        with tempfile.TemporaryDirectory() as cache_td, tempfile.TemporaryDirectory() as src_td:
            cache_root = Path(cache_td)
            src_root = Path(src_td)

            main_dir = src_root / "flux-main"
            main_dir.mkdir(parents=True, exist_ok=True)
            (main_dir / "flux-2-klein-base-4b-Q8_0.gguf").write_bytes(b"GGUF")
            import_directory_to_hf_cache(
                main_dir,
                repo_id="leejet/FLUX.2-klein-base-4B-GGUF",
                cache_dir=str(cache_root),
                cleanup_source=False,
            )

            vae_dir = src_root / "flux-vae"
            (vae_dir / "vae").mkdir(parents=True, exist_ok=True)
            (vae_dir / "vae" / "diffusion_pytorch_model.safetensors").write_bytes(b"VAE")
            import_directory_to_hf_cache(
                vae_dir,
                repo_id="black-forest-labs/FLUX.2-klein-base-4B",
                cache_dir=str(cache_root),
                cleanup_source=False,
            )

            llm_dir = src_root / "qwen3"
            llm_dir.mkdir(parents=True, exist_ok=True)
            (llm_dir / "Qwen3-4B-Q4_K_M.gguf").write_bytes(b"GGUF")
            import_directory_to_hf_cache(
                llm_dir,
                repo_id="unsloth/Qwen3-4B-GGUF",
                cache_dir=str(cache_root),
                cleanup_source=False,
            )

            with patch.dict("os.environ", {"HF_HUB_CACHE": cache_td}, clear=True):
                state = _ReplState()
                state.backend_kind = "sdcpp"
                state.sdcpp_bin = "sd-cli"
                state.sdcpp_model = "flux2-klein-base-4b"

                vm = _build_manager_from_state(state)
                backend = vm.backend
                cfg = getattr(backend, "_cfg", None)
                self.assertIsNotNone(cfg)
                self.assertIsNone(getattr(cfg, "model", None))
                self.assertTrue(
                    str(getattr(cfg, "diffusion_model", "")).endswith(
                        "flux-2-klein-base-4b-Q8_0.gguf"
                    )
                )
                self.assertTrue(
                    str(getattr(cfg, "vae", "")).endswith("vae/diffusion_pytorch_model.safetensors")
                )
                self.assertTrue(str(getattr(cfg, "llm", "")).endswith("Qwen3-4B-Q4_K_M.gguf"))


if __name__ == "__main__":
    unittest.main()
