"""Tests for the integration catalog system (catalog.py)."""

import json
import os

import pytest
import yaml

from specify_cli.integrations.catalog import (
    IntegrationCatalog,
    IntegrationCatalogEntry,
    IntegrationCatalogError,
    IntegrationDescriptor,
    IntegrationDescriptorError,
    IntegrationValidationError,
)


# ---------------------------------------------------------------------------
# IntegrationCatalogEntry
# ---------------------------------------------------------------------------


class TestIntegrationCatalogEntry:
    def test_create_entry(self):
        entry = IntegrationCatalogEntry(
            url="https://example.com/catalog.json",
            name="test",
            priority=1,
            install_allowed=True,
            description="Test catalog",
        )
        assert entry.url == "https://example.com/catalog.json"
        assert entry.name == "test"
        assert entry.priority == 1
        assert entry.install_allowed is True
        assert entry.description == "Test catalog"

    def test_default_description(self):
        entry = IntegrationCatalogEntry(
            url="https://example.com/catalog.json",
            name="test",
            priority=1,
            install_allowed=False,
        )
        assert entry.description == ""


# ---------------------------------------------------------------------------
# IntegrationCatalog — URL validation
# ---------------------------------------------------------------------------


class TestCatalogURLValidation:
    def test_https_allowed(self):
        IntegrationCatalog._validate_catalog_url("https://example.com/catalog.json")

    def test_http_rejected(self):
        with pytest.raises(IntegrationCatalogError, match="HTTPS"):
            IntegrationCatalog._validate_catalog_url("http://example.com/catalog.json")

    def test_http_localhost_allowed(self):
        IntegrationCatalog._validate_catalog_url("http://localhost:8080/catalog.json")
        IntegrationCatalog._validate_catalog_url("http://127.0.0.1/catalog.json")

    def test_missing_host_rejected(self):
        with pytest.raises(IntegrationCatalogError, match="valid URL"):
            IntegrationCatalog._validate_catalog_url("https:///no-host")

    @pytest.mark.parametrize(
        "url",
        [
            "https://:8080",                # port only, no host
            "https://:8080/catalog.json",   # port only, with path
            "https://:0",                   # port only, no host
            "https://user@",                # userinfo only, no host
            "https://user:pass@",           # userinfo only, no host
        ],
    )
    def test_hostless_url_with_truthy_netloc_rejected(self, url):
        # These have a truthy netloc (":8080", "user@") but no actual host,
        # so a netloc-based check would wrongly accept them despite the
        # "valid URL with a host" promise. hostname is None for all of them (#3209).
        with pytest.raises(IntegrationCatalogError, match="valid URL"):
            IntegrationCatalog._validate_catalog_url(url)

    @pytest.mark.parametrize(
        "url",
        [
            "https://[::1",                 # unclosed ipv6 bracket
            "https://[not-an-ip]/c.json",   # bracketed non-ip host
        ],
    )
    def test_malformed_url_rejected_cleanly(self, url):
        # A malformed authority makes urlparse/hostname raise ValueError. The
        # validator must turn that into its normal catalog error, not leak a
        # raw ValueError to the caller.
        with pytest.raises(IntegrationCatalogError, match="malformed"):
            IntegrationCatalog._validate_catalog_url(url)


# ---------------------------------------------------------------------------
# IntegrationCatalog — active catalogs
# ---------------------------------------------------------------------------


class TestActiveCatalogs:
    def test_defaults_when_no_config(self, tmp_path, monkeypatch):
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        (tmp_path / ".specify").mkdir()
        cat = IntegrationCatalog(tmp_path)
        active = cat.get_active_catalogs()
        assert len(active) == 2
        assert active[0].name == "default"
        assert active[1].name == "community"

    def test_env_var_override(self, tmp_path, monkeypatch):
        (tmp_path / ".specify").mkdir()
        monkeypatch.setenv(
            "SPECKIT_INTEGRATION_CATALOG_URL",
            "https://custom.example.com/catalog.json",
        )
        cat = IntegrationCatalog(tmp_path)
        active = cat.get_active_catalogs()
        assert len(active) == 1
        assert active[0].name == "custom"

    def test_project_config_overrides_defaults(self, tmp_path):
        specify = tmp_path / ".specify"
        specify.mkdir()
        cfg = specify / "integration-catalogs.yml"
        cfg.write_text(yaml.dump({
            "catalogs": [
                {"url": "https://my.example.com/cat.json", "name": "mine", "priority": 1, "install_allowed": True},
            ]
        }))
        cat = IntegrationCatalog(tmp_path)
        active = cat.get_active_catalogs()
        assert len(active) == 1
        assert active[0].name == "mine"

    def test_empty_config_raises(self, tmp_path):
        specify = tmp_path / ".specify"
        specify.mkdir()
        cfg = specify / "integration-catalogs.yml"
        cfg.write_text(yaml.dump({"catalogs": []}))
        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries") as exc_info:
            cat.get_active_catalogs()
        assert str(cfg) in str(exc_info.value)

    def test_empty_config_file_raises_no_catalogs(self, tmp_path, monkeypatch):
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        specify = tmp_path / ".specify"
        specify.mkdir()
        cfg = specify / "integration-catalogs.yml"
        cfg.write_text("", encoding="utf-8")

        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError, match="no 'catalogs' entries"
        ) as exc_info:
            cat.get_active_catalogs()
        assert str(cfg) in str(exc_info.value)

    @pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
    def test_load_catalog_config_rejects_falsy_non_mapping_roots(
        self, tmp_path, monkeypatch, config_content
    ):
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        specify = tmp_path / ".specify"
        specify.mkdir()
        cfg = specify / "integration-catalogs.yml"
        cfg.write_text(config_content, encoding="utf-8")

        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError,
            match="expected a YAML mapping at the root",
        ) as exc_info:
            cat.get_active_catalogs()
        assert str(cfg) in str(exc_info.value)


# ---------------------------------------------------------------------------
# IntegrationCatalog — fetch & search (using monkeypatched urlopen responses)
# ---------------------------------------------------------------------------


class TestCatalogFetch:
    """Tests that use a local HTTP server stub via monkeypatch."""

    def _patch_urlopen(self, monkeypatch, catalog_data):
        """Patch authentication.http.urllib.request.urlopen to return *catalog_data*."""

        class FakeResponse:
            def __init__(self, data, url=""):
                self._data = json.dumps(data).encode()
                self._url = url if isinstance(url, str) else url.full_url

            def read(self):
                return self._data

            def geturl(self):
                return self._url

            def __enter__(self):
                return self

            def __exit__(self, *a):
                pass

        def fake_urlopen(req, timeout=10):
            url = req if isinstance(req, str) else req.full_url
            return FakeResponse(catalog_data, url)

        import specify_cli.authentication.http as _auth_http
        monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)

    def test_fetch_and_search_all(self, tmp_path, monkeypatch):
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        (tmp_path / ".specify").mkdir()
        cat = IntegrationCatalog(tmp_path)

        catalog = {
            "schema_version": "1.0",
            "updated_at": "2026-01-01T00:00:00Z",
            "integrations": {
                "acme-coder": {
                    "id": "acme-coder",
                    "name": "Acme Coder",
                    "version": "2.0.0",
                    "description": "Community integration for Acme Coder",
                    "author": "acme-org",
                    "tags": ["cli"],
                },
            },
        }
        self._patch_urlopen(monkeypatch, catalog)

        results = cat.search()
        assert len(results) >= 1
        ids = [r["id"] for r in results]
        assert "acme-coder" in ids

    def test_search_by_tag(self, tmp_path, monkeypatch):
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        (tmp_path / ".specify").mkdir()
        cat = IntegrationCatalog(tmp_path)

        catalog = {
            "schema_version": "1.0",
            "updated_at": "2026-01-01T00:00:00Z",
            "integrations": {
                "a": {"id": "a", "name": "A", "version": "1.0.0", "tags": ["cli"]},
                "b": {"id": "b", "name": "B", "version": "1.0.0", "tags": ["ide"]},
            },
        }
        self._patch_urlopen(monkeypatch, catalog)

        results = cat.search(tag="cli")
        assert all("cli" in r.get("tags", []) for r in results)

    def test_search_by_query(self, tmp_path, monkeypatch):
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        (tmp_path / ".specify").mkdir()
        cat = IntegrationCatalog(tmp_path)

        catalog = {
            "schema_version": "1.0",
            "updated_at": "2026-01-01T00:00:00Z",
            "integrations": {
                "claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0", "description": "Anthropic", "tags": []},
                "gemini": {"id": "gemini", "name": "Gemini CLI", "version": "1.0.0", "description": "Google", "tags": []},
            },
        }
        self._patch_urlopen(monkeypatch, catalog)

        results = cat.search(query="claude")
        assert len(results) == 1
        assert results[0]["id"] == "claude"

    def test_get_integration_info(self, tmp_path, monkeypatch):
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        (tmp_path / ".specify").mkdir()
        cat = IntegrationCatalog(tmp_path)

        catalog = {
            "schema_version": "1.0",
            "updated_at": "2026-01-01T00:00:00Z",
            "integrations": {
                "claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0"},
            },
        }
        self._patch_urlopen(monkeypatch, catalog)

        info = cat.get_integration_info("claude")
        assert info is not None
        assert info["name"] == "Claude Code"

        assert cat.get_integration_info("nonexistent") is None

    def test_invalid_catalog_format(self, tmp_path, monkeypatch):
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        (tmp_path / ".specify").mkdir()
        cat = IntegrationCatalog(tmp_path)

        self._patch_urlopen(monkeypatch, {"schema_version": "1.0"})  # missing "integrations"

        with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
            cat.search()

    def test_clear_cache(self, tmp_path):
        (tmp_path / ".specify").mkdir()
        cat = IntegrationCatalog(tmp_path)
        cat.cache_dir.mkdir(parents=True, exist_ok=True)
        (cat.cache_dir / "catalog-abc123.json").write_text("{}")
        cat.clear_cache()
        assert not list(cat.cache_dir.glob("catalog-*.json"))


# ---------------------------------------------------------------------------
# IntegrationDescriptor (integration.yml)
# ---------------------------------------------------------------------------

VALID_DESCRIPTOR = {
    "schema_version": "1.0",
    "integration": {
        "id": "my-agent",
        "name": "My Agent",
        "version": "1.0.0",
        "description": "Integration for My Agent",
        "author": "my-org",
    },
    "requires": {
        "speckit_version": ">=0.6.0",
    },
    "provides": {
        "commands": [
            {"name": "speckit.specify", "file": "templates/speckit.specify.md"},
        ],
        "scripts": [],
    },
}


class TestIntegrationDescriptor:
    def _write(self, tmp_path, data):
        p = tmp_path / "integration.yml"
        p.write_text(yaml.dump(data))
        return p

    def test_valid_descriptor(self, tmp_path):
        p = self._write(tmp_path, VALID_DESCRIPTOR)
        desc = IntegrationDescriptor(p)
        assert desc.id == "my-agent"
        assert desc.name == "My Agent"
        assert desc.version == "1.0.0"
        assert desc.description == "Integration for My Agent"
        assert desc.requires_speckit_version == ">=0.6.0"
        assert len(desc.commands) == 1
        assert desc.scripts == []

    def test_missing_schema_version(self, tmp_path):
        data = {**VALID_DESCRIPTOR}
        del data["schema_version"]
        p = self._write(tmp_path, data)
        with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"):
            IntegrationDescriptor(p)

    def test_unsupported_schema_version(self, tmp_path):
        data = {**VALID_DESCRIPTOR, "schema_version": "99.0"}
        p = self._write(tmp_path, data)
        with pytest.raises(IntegrationDescriptorError, match="Unsupported schema version"):
            IntegrationDescriptor(p)

    def test_missing_integration_id(self, tmp_path):
        data = {**VALID_DESCRIPTOR, "integration": {"name": "X", "version": "1.0.0", "description": "Y"}}
        p = self._write(tmp_path, data)
        with pytest.raises(IntegrationDescriptorError, match="Missing integration.id"):
            IntegrationDescriptor(p)

    def test_invalid_id_format(self, tmp_path):
        integ = {**VALID_DESCRIPTOR["integration"], "id": "BAD_ID"}
        data = {**VALID_DESCRIPTOR, "integration": integ}
        p = self._write(tmp_path, data)
        with pytest.raises(IntegrationDescriptorError, match="Invalid integration ID"):
            IntegrationDescriptor(p)

    def test_invalid_version(self, tmp_path):
        integ = {**VALID_DESCRIPTOR["integration"], "version": "not-semver"}
        data = {**VALID_DESCRIPTOR, "integration": integ}
        p = self._write(tmp_path, data)
        with pytest.raises(IntegrationDescriptorError, match="Invalid version"):
            IntegrationDescriptor(p)

    def test_missing_speckit_version(self, tmp_path):
        data = {**VALID_DESCRIPTOR, "requires": {}}
        p = self._write(tmp_path, data)
        with pytest.raises(IntegrationDescriptorError, match="requires.speckit_version"):
            IntegrationDescriptor(p)

    def test_no_commands_or_scripts(self, tmp_path):
        data = {**VALID_DESCRIPTOR, "provides": {}}
        p = self._write(tmp_path, data)
        with pytest.raises(IntegrationDescriptorError, match="at least one command or script"):
            IntegrationDescriptor(p)

    def test_command_missing_name(self, tmp_path):
        data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"file": "x.md"}]}}
        p = self._write(tmp_path, data)
        with pytest.raises(IntegrationDescriptorError, match="missing 'name' or 'file'"):
            IntegrationDescriptor(p)

    def test_commands_not_a_list(self, tmp_path):
        data = {**VALID_DESCRIPTOR, "provides": {"commands": "not-a-list", "scripts": ["a.sh"]}}
        p = self._write(tmp_path, data)
        with pytest.raises(IntegrationDescriptorError, match="expected a list"):
            IntegrationDescriptor(p)

    def test_scripts_not_a_list(self, tmp_path):
        data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"name": "a", "file": "b"}], "scripts": "not-a-list"}}
        p = self._write(tmp_path, data)
        with pytest.raises(IntegrationDescriptorError, match="expected a list"):
            IntegrationDescriptor(p)

    def test_file_not_found(self, tmp_path):
        with pytest.raises(IntegrationDescriptorError, match="Descriptor not found"):
            IntegrationDescriptor(tmp_path / "nonexistent.yml")

    def test_invalid_yaml(self, tmp_path):
        p = tmp_path / "integration.yml"
        p.write_text(": : :")
        with pytest.raises(IntegrationDescriptorError, match="Invalid YAML"):
            IntegrationDescriptor(p)

    def test_get_hash(self, tmp_path):
        p = self._write(tmp_path, VALID_DESCRIPTOR)
        desc = IntegrationDescriptor(p)
        h = desc.get_hash()
        assert h.startswith("sha256:")

    def test_tools_accessor(self, tmp_path):
        data = {**VALID_DESCRIPTOR, "requires": {
            "speckit_version": ">=0.6.0",
            "tools": [{"name": "my-agent", "version": ">=1.0.0", "required": True}],
        }}
        p = self._write(tmp_path, data)
        desc = IntegrationDescriptor(p)
        assert len(desc.tools) == 1
        assert desc.tools[0]["name"] == "my-agent"


# ---------------------------------------------------------------------------
# CLI: integration list --catalog
# ---------------------------------------------------------------------------


class TestIntegrationListCatalog:
    """Test ``specify integration list --catalog``."""

    def _init_project(self, tmp_path):
        """Create a minimal spec-kit project."""
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = tmp_path / "proj"
        project.mkdir()
        old = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "init", "--here",
                "--integration", "copilot",
                "--script", "sh",
                "--ignore-agent-tools",
            ], catch_exceptions=False)
        finally:
            os.chdir(old)
        assert result.exit_code == 0, result.output
        return project

    def test_list_catalog_flag(self, tmp_path, monkeypatch):
        """--catalog should show catalog entries."""
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = self._init_project(tmp_path)

        catalog = {
            "schema_version": "1.0",
            "updated_at": "2026-01-01T00:00:00Z",
            "integrations": {
                "test-agent": {
                    "id": "test-agent",
                    "name": "Test Agent",
                    "version": "1.0.0",
                    "description": "A test agent",
                    "tags": ["cli"],
                },
            },
        }

        import specify_cli.authentication.http as _auth_http

        class FakeResponse:
            def __init__(self, data, url=""):
                self._data = json.dumps(data).encode()
                self._url = url if isinstance(url, str) else url.full_url
            def read(self):
                return self._data
            def geturl(self):
                return self._url
            def __enter__(self):
                return self
            def __exit__(self, *a):
                pass

        monkeypatch.setattr(_auth_http.urllib.request, "urlopen",
                            lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url))

        old = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "list", "--catalog"])
        finally:
            os.chdir(old)

        assert result.exit_code == 0
        assert "test-agent" in result.output
        assert "Test Agent" in result.output

    def test_list_without_catalog_still_works(self, tmp_path):
        """Default list (no --catalog) works as before."""
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = self._init_project(tmp_path)

        old = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "list"])
        finally:
            os.chdir(old)

        assert result.exit_code == 0
        assert "copilot" in result.output
        assert "installed" in result.output


# ---------------------------------------------------------------------------
# CLI: integration upgrade
# ---------------------------------------------------------------------------


class TestIntegrationUpgrade:
    """Test ``specify integration upgrade``."""

    def _init_project(self, tmp_path, integration="copilot"):
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = tmp_path / "proj"
        project.mkdir()
        old = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "init", "--here",
                "--integration", integration,
                "--script", "sh",
                "--ignore-agent-tools",
            ], catch_exceptions=False)
        finally:
            os.chdir(old)
        assert result.exit_code == 0, result.output
        return project

    def test_upgrade_requires_speckit_project(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        old = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["integration", "upgrade"])
        finally:
            os.chdir(old)
        assert result.exit_code != 0
        assert "Not a Spec Kit project" in result.output

    def test_upgrade_no_integration_installed(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = tmp_path / "proj"
        project.mkdir()
        (project / ".specify").mkdir()
        old = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "upgrade"])
        finally:
            os.chdir(old)
        assert result.exit_code == 0
        assert "No integration is currently installed" in result.output

    def test_upgrade_succeeds(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = self._init_project(tmp_path, "copilot")

        old = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "upgrade"], catch_exceptions=False)
        finally:
            os.chdir(old)
        assert result.exit_code == 0
        assert "upgraded successfully" in result.output

    def test_upgrade_blocks_on_modified_files(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = self._init_project(tmp_path, "copilot")

        # Modify a tracked file so the manifest hash won't match
        manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
        assert manifest_path.exists(), "Manifest should exist after init"
        manifest_data = json.loads(manifest_path.read_text())
        tracked_files = manifest_data.get("files", {})
        assert tracked_files, "Manifest should track at least one file"
        first_rel = next(iter(tracked_files))
        target_file = project / first_rel
        assert target_file.exists(), f"Tracked file {first_rel} should exist"
        target_file.write_text("MODIFIED CONTENT\n")

        old = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "upgrade"])
        finally:
            os.chdir(old)
        assert result.exit_code != 0
        assert "modified" in result.output.lower()

    def test_upgrade_force_overwrites_modified(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = self._init_project(tmp_path, "copilot")

        # Modify a tracked file
        manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
        manifest_data = json.loads(manifest_path.read_text())
        tracked_files = manifest_data.get("files", {})
        assert tracked_files, "Manifest should track at least one file"
        first_rel = next(iter(tracked_files))
        target_file = project / first_rel
        assert target_file.exists(), f"Tracked file {first_rel} should exist"
        target_file.write_text("MODIFIED CONTENT\n")

        old = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "upgrade", "--force"], catch_exceptions=False)
        finally:
            os.chdir(old)
        assert result.exit_code == 0
        assert "upgraded successfully" in result.output

    def test_upgrade_wrong_integration_key(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = self._init_project(tmp_path, "copilot")

        old = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "upgrade", "claude"])
        finally:
            os.chdir(old)
        assert result.exit_code != 0
        assert "not installed" in result.output

    def test_upgrade_no_manifest(self, tmp_path):
        """Upgrade with missing manifest suggests fresh install."""
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = self._init_project(tmp_path, "copilot")

        # Remove manifest
        manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
        if manifest_path.exists():
            manifest_path.unlink()

        old = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "upgrade"])
        finally:
            os.chdir(old)
        assert result.exit_code == 0
        assert "Nothing to upgrade" in result.output


# ---------------------------------------------------------------------------
# IntegrationCatalog — catalog source management (get_catalog_configs / add / remove)
# ---------------------------------------------------------------------------


class TestCatalogSourceManagement:
    """Unit tests for add_catalog / remove_catalog / get_catalog_configs."""

    def _isolate(self, tmp_path, monkeypatch):
        """Point HOME at tmp_path and clear the env override so we read built-ins."""
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        (tmp_path / ".specify").mkdir()

    def test_get_catalog_configs_returns_builtin_stack(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        configs = cat.get_catalog_configs()
        assert [c["name"] for c in configs] == ["default", "community"]
        assert all(isinstance(c["url"], str) and c["url"] for c in configs)
        assert configs[0]["install_allowed"] is True
        assert configs[1]["install_allowed"] is False

    def test_add_catalog_creates_config_file(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://new.example.com/catalog.json", name="mine")

        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        assert cfg_path.exists()
        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        assert data["catalogs"] == [
            {
                "name": "mine",
                "url": "https://new.example.com/catalog.json",
                "priority": 1,
                "install_allowed": True,
                "description": "",
            }
        ]
        # Round-trip: active catalogs should now come from the config file.
        active = cat.get_active_catalogs()
        assert [e.name for e in active] == ["mine"]

    def test_add_catalog_recovers_from_empty_config_file(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text("", encoding="utf-8")

        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://example.com/catalog.json")

        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        assert data["catalogs"] == [
            {
                "name": "catalog-1",
                "url": "https://example.com/catalog.json",
                "priority": 1,
                "install_allowed": True,
                "description": "",
            }
        ]

    @pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
    def test_add_catalog_rejects_falsy_non_mapping_config_roots(
        self, tmp_path, monkeypatch, config_content
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(config_content, encoding="utf-8")

        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError,
            match="corrupted.*expected a mapping",
        ) as exc_info:
            cat.add_catalog("https://example.com/catalog.json")
        assert str(cfg_path) in str(exc_info.value)

    def test_add_catalog_auto_derives_name_and_priority(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://a.example.com/catalog.json")
        cat.add_catalog("https://b.example.com/catalog.json")

        data = yaml.safe_load(
            (tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8")
        )
        entries = data["catalogs"]
        assert [e["name"] for e in entries] == ["catalog-1", "catalog-2"]
        assert [e["priority"] for e in entries] == [1, 2]

    def test_add_catalog_normalizes_name(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://a.example.com/catalog.json", name="  mine  ")
        cat.add_catalog("https://b.example.com/catalog.json", name="   ")

        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        entries = data["catalogs"]
        assert [e["name"] for e in entries] == ["mine", "catalog-2"]

    def test_add_catalog_rejects_duplicate_url(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://dup.example.com/catalog.json")
        with pytest.raises(IntegrationValidationError, match="already configured"):
            cat.add_catalog("https://dup.example.com/catalog.json")

    def test_add_catalog_rejects_invalid_url(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(IntegrationCatalogError, match="HTTPS"):
            cat.add_catalog("http://insecure.example.com/catalog.json")
        assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists()

    def test_add_catalog_rejects_empty_url(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(IntegrationValidationError, match="must be non-empty"):
            cat.add_catalog("   ")
        assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists()

    def test_remove_catalog_without_config_errors(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(IntegrationValidationError, match="No catalog config"):
            cat.remove_catalog(0)

    def test_remove_catalog_happy_path(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://a.example.com/catalog.json", name="a")
        cat.add_catalog("https://b.example.com/catalog.json", name="b")

        removed = cat.remove_catalog(0)
        assert removed == "a"

        data = yaml.safe_load(
            (tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8")
        )
        assert [e["name"] for e in data["catalogs"]] == ["b"]

    def test_remove_catalog_index_out_of_range(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://a.example.com/catalog.json", name="a")
        with pytest.raises(IntegrationValidationError, match="out of range"):
            cat.remove_catalog(5)
        with pytest.raises(IntegrationValidationError, match="out of range"):
            cat.remove_catalog(-1)

    def test_corrupt_config_rejected_on_add(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text("- just\n- a\n- list\n", encoding="utf-8")
        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(IntegrationValidationError, match="corrupted") as exc_info:
            cat.add_catalog("https://new.example.com/catalog.json")
        assert str(cfg_path) in str(exc_info.value)

    def test_add_catalog_rejects_non_list_catalogs_with_config_path(
        self, tmp_path, monkeypatch
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8"
        )

        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError, match="invalid 'catalogs' value"
        ) as exc_info:
            cat.add_catalog("https://new.example.com/catalog.json")
        assert str(cfg_path) in str(exc_info.value)

    def test_add_catalog_rejects_non_mapping_entry_with_config_path(
        self, tmp_path, monkeypatch
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump({"catalogs": ["not-a-mapping"]}), encoding="utf-8"
        )

        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError, match="Invalid catalog entry at index 0"
        ) as exc_info:
            cat.add_catalog("https://new.example.com/catalog.json")
        message = str(exc_info.value)
        assert str(cfg_path) in message
        assert "expected a mapping" in message

    def test_add_catalog_skips_blank_url_entries(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {"url": "   ", "name": "blank", "priority": 99},
                        {
                            "url": "https://a.example.com/catalog.json",
                            "name": "a",
                            "priority": 5,
                        },
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://b.example.com/catalog.json", name="b")

        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        assert data["catalogs"][-1]["name"] == "b"
        assert data["catalogs"][-1]["priority"] == 6

    def test_add_catalog_default_name_ignores_blank_url_entries(
        self, tmp_path, monkeypatch
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump({"catalogs": [{"url": "   ", "name": "blank"}]}),
            encoding="utf-8",
        )

        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://example.com/catalog.json")

        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        assert data["catalogs"][-1]["name"] == "catalog-1"

    def test_add_catalog_rejects_non_integer_priority(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {
                            "url": "https://a.example.com/catalog.json",
                            "name": "a",
                            "priority": "first",
                        }
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError,
            match="'priority' must be an integer, got 'first'",
        ):
            cat.add_catalog("https://b.example.com/catalog.json")

    def test_add_catalog_accepts_numeric_string_priority(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {
                            "url": "https://a.example.com/catalog.json",
                            "name": "a",
                            "priority": "10",
                        }
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://b.example.com/catalog.json", name="b")

        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        assert data["catalogs"][-1]["name"] == "b"
        assert data["catalogs"][-1]["priority"] == 11

    @pytest.mark.parametrize(
        ("bad_url", "reason"),
        [
            ("http://insecure.example.com/catalog.json", "HTTPS"),
            (123, "HTTPS"),
        ],
    )
    def test_add_catalog_rejects_existing_entry_with_bad_url(
        self, tmp_path, monkeypatch, bad_url, reason
    ):
        """A sibling entry with an http:// URL should block a new add."""
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {
                            "url": bad_url,
                            "name": "bad",
                        }
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(IntegrationValidationError) as exc_info:
            cat.add_catalog("https://good.example.com/catalog.json")
        message = str(exc_info.value)
        assert str(cfg_path) in message
        assert "index 0" in message
        assert reason in message

    def test_add_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch):
        """Invalid YAML on disk surfaces as IntegrationValidationError, not a raw YAMLError."""
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        invalid_yaml = "catalogs:\n  - url: 'https://a.example.com/cat.json'\n  - [bad\n"
        cfg_path.write_text(invalid_yaml, encoding="utf-8")
        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError, match="Failed to read catalog config"
        ):
            cat.add_catalog("https://b.example.com/catalog.json")

    def test_remove_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch):
        """Invalid YAML on disk surfaces as IntegrationValidationError from remove_catalog too."""
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        invalid_yaml = "catalogs:\n  - url: 'https://a.example.com/cat.json'\n  - [bad\n"
        cfg_path.write_text(invalid_yaml, encoding="utf-8")
        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError, match="Failed to read catalog config"
        ):
            cat.remove_catalog(0)

    def test_add_catalog_defaults_missing_priority_to_index_plus_one(
        self, tmp_path, monkeypatch
    ):
        """Existing entries without `priority` should be treated as idx + 1.

        Matches the rule in `_load_catalog_config()`: a valid catalog entry
        without an explicit `priority` sorts at `idx + 1`, so the new entry
        should get `max(...) + 1` from those derived values.
        """
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        # No explicit priority → should be treated as 1
                        {"url": "https://a.example.com/cat.json", "name": "a"},
                        # No explicit priority → should be treated as 2
                        {"url": "https://b.example.com/cat.json", "name": "b"},
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://c.example.com/cat.json", name="c")

        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        new_entry = data["catalogs"][-1]
        assert new_entry["name"] == "c"
        # max(implicit [1, 2]) + 1 == 3
        assert new_entry["priority"] == 3

    def test_add_catalog_strips_whitespace_in_url(self, tmp_path, monkeypatch):
        """Whitespace around the incoming URL should be normalized before write."""
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("  https://a.example.com/catalog.json\n", name="a")

        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        assert data["catalogs"][0]["url"] == "https://a.example.com/catalog.json"

    def test_add_catalog_rejects_whitespace_only_duplicate(self, tmp_path, monkeypatch):
        """A second add with only whitespace differences must be rejected as a duplicate."""
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://a.example.com/catalog.json", name="a")
        with pytest.raises(IntegrationValidationError, match="already configured"):
            cat.add_catalog("  https://a.example.com/catalog.json  ")

    def test_remove_catalog_wraps_unlink_oserror(self, tmp_path, monkeypatch):
        """An OSError from `Path.unlink` surfaces as IntegrationValidationError."""
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://only.example.com/catalog.json", name="only")

        from pathlib import Path as _Path

        def boom(self, *args, **kwargs):
            raise OSError("simulated unlink failure")

        monkeypatch.setattr(_Path, "unlink", boom)

        with pytest.raises(
            IntegrationValidationError, match="Failed to delete catalog config"
        ):
            cat.remove_catalog(0)

    def test_remove_catalog_ignores_missing_final_config_during_unlink(
        self, tmp_path, monkeypatch
    ):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://only.example.com/catalog.json", name="only")
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"

        from pathlib import Path as _Path

        original_unlink = _Path.unlink

        def delete_first_then_unlink(self, *args, **kwargs):
            if self == cfg_path and self.exists():
                original_unlink(self)
            return original_unlink(self, *args, **kwargs)

        monkeypatch.setattr(_Path, "unlink", delete_first_then_unlink)

        assert cat.remove_catalog(0) == "only"
        assert not cfg_path.exists()

    def test_remove_catalog_empty_list_gives_clear_error(self, tmp_path, monkeypatch):
        """Hand-edited empty `catalogs:` produces a clear error, not '0--1'."""
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(yaml.dump({"catalogs": []}), encoding="utf-8")
        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError, match="contains no catalog entries"
        ):
            cat.remove_catalog(0)

    def test_remove_catalog_empty_config_file_gives_clear_error(
        self, tmp_path, monkeypatch
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text("", encoding="utf-8")

        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError, match="contains no catalog entries"
        ):
            cat.remove_catalog(0)

    def test_remove_catalog_rejects_non_list_catalogs_with_config_path(
        self, tmp_path, monkeypatch
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8"
        )

        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError, match="invalid 'catalogs' value"
        ) as exc_info:
            cat.remove_catalog(0)
        assert str(cfg_path) in str(exc_info.value)

    @pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
    def test_remove_catalog_rejects_falsy_non_mapping_config_roots(
        self, tmp_path, monkeypatch, config_content
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(config_content, encoding="utf-8")

        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError,
            match="corrupted.*expected a mapping",
        ) as exc_info:
            cat.remove_catalog(0)
        assert str(cfg_path) in str(exc_info.value)

    def test_remove_last_catalog_deletes_file_and_restores_defaults(
        self, tmp_path, monkeypatch
    ):
        """Removing the final catalog must not leave behind `catalogs: []`.

        `_load_catalog_config` treats an empty `catalogs` list as an error,
        so writing that file would break every subsequent `integration`
        command. Removing the last entry should delete the config file so the
        project falls back to built-in defaults.
        """
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"

        cat.add_catalog("https://only.example.com/catalog.json", name="only")
        assert cfg_path.exists()
        assert [e.name for e in cat.get_active_catalogs()] == ["only"]

        removed = cat.remove_catalog(0)
        assert removed == "only"

        assert not cfg_path.exists(), (
            "remove_catalog should delete the config file when emptying it"
        )
        # Follow-up loads fall back to built-in defaults, not an error.
        active = cat.get_active_catalogs()
        assert [e.name for e in active] == ["default", "community"]

    def test_load_catalog_config_raises_validation_error_for_invalid_yaml(
        self, tmp_path, monkeypatch
    ):
        """Local-config problems must surface as IntegrationValidationError so
        CLI handlers can route them to local-config (not network) guidance."""
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.parent.mkdir(parents=True, exist_ok=True)
        invalid_yaml = "catalogs:\n  - [bad\n"
        cfg_path.write_text(invalid_yaml, encoding="utf-8")

        cat = IntegrationCatalog(tmp_path)
        # Subclass match: IntegrationValidationError (specifically), not the
        # bare IntegrationCatalogError parent that callers used previously.
        with pytest.raises(IntegrationValidationError, match="Failed to read catalog config"):
            cat.get_active_catalogs()

    def test_load_catalog_config_rejects_boolean_priority(self, tmp_path, monkeypatch):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.parent.mkdir(parents=True, exist_ok=True)
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {
                            "url": "https://a.example.com/catalog.json",
                            "name": "a",
                            "priority": True,
                        }
                    ]
                }
            ),
            encoding="utf-8",
        )

        cat = IntegrationCatalog(tmp_path)
        with pytest.raises(
            IntegrationValidationError, match="Invalid priority|expected integer"
        ) as exc_info:
            cat.get_active_catalogs()
        assert str(cfg_path) in str(exc_info.value)

    @pytest.mark.parametrize("raw_name", [None, "   "])
    def test_load_catalog_config_defaults_blank_names(
        self, tmp_path, monkeypatch, raw_name
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {
                            "url": "   ",
                            "name": "skipped",
                        },
                        {
                            "url": "https://example.com/catalog.json",
                            "name": raw_name,
                        }
                    ]
                }
            ),
            encoding="utf-8",
        )

        cat = IntegrationCatalog(tmp_path)

        assert [entry.name for entry in cat.get_active_catalogs()] == ["catalog-1"]

    @pytest.mark.parametrize(
        ("raw_name", "expected"),
        [
            (None, "https://one.example.com/c.json"),
            ("   ", "https://one.example.com/c.json"),
            (123, "123"),
        ],
    )
    def test_remove_catalog_normalizes_removed_display_name(
        self, tmp_path, monkeypatch, raw_name, expected
    ):
        self._isolate(tmp_path, monkeypatch)
        cat = IntegrationCatalog(tmp_path)
        cat.add_catalog("https://one.example.com/c.json", name="one")

        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        data["catalogs"][0]["name"] = raw_name
        cfg_path.write_text(yaml.dump(data), encoding="utf-8")

        assert cat.remove_catalog(0) == expected

    def test_remove_catalog_uses_display_order_with_explicit_priorities(
        self, tmp_path, monkeypatch
    ):
        """`remove_catalog(index)` must remove the entry shown at that index by
        `catalog list`, not the entry at that raw YAML position."""
        self._isolate(tmp_path, monkeypatch)
        # YAML order: alpha (priority=20), beta (priority=10), gamma (priority=15).
        # Display (sorted by priority asc): beta (10), gamma (15), alpha (20).
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.parent.mkdir(parents=True, exist_ok=True)
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {"url": "https://alpha.example.com/c.json", "name": "alpha", "priority": 20},
                        {"url": "https://beta.example.com/c.json", "name": "beta", "priority": 10},
                        {"url": "https://gamma.example.com/c.json", "name": "gamma", "priority": 15},
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)

        # Display index 0 = beta (lowest priority), not alpha (raw YAML idx 0).
        removed = cat.remove_catalog(0)
        assert removed == "beta"

        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        remaining_names = [c["name"] for c in data["catalogs"]]
        # YAML order is preserved for the survivors; only beta is gone.
        assert remaining_names == ["alpha", "gamma"]

    def test_remove_catalog_display_order_with_missing_priorities(
        self, tmp_path, monkeypatch
    ):
        """Entries without `priority` default to `idx + 1` (matching
        `_load_catalog_config`), so display order tracks YAML order and the
        first display entry is the first YAML entry."""
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.parent.mkdir(parents=True, exist_ok=True)
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {"url": "https://one.example.com/c.json", "name": "one"},
                        {"url": "https://two.example.com/c.json", "name": "two"},
                        {"url": "https://three.example.com/c.json", "name": "three"},
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)

        # Implicit priorities: one=1, two=2, three=3 → display order matches YAML.
        removed = cat.remove_catalog(0)
        assert removed == "one"

        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        assert [c["name"] for c in data["catalogs"]] == ["two", "three"]

    def test_remove_catalog_bool_priority_falls_back_to_yaml_index(
        self, tmp_path, monkeypatch
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.parent.mkdir(parents=True, exist_ok=True)
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {"url": "https://one.example.com/c.json", "name": "one"},
                        {
                            "url": "https://bool.example.com/c.json",
                            "name": "bool",
                            "priority": False,
                        },
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)

        removed = cat.remove_catalog(0)

        assert removed == "one"
        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        assert [c["name"] for c in data["catalogs"]] == ["bool"]

    def test_remove_catalog_display_order_skips_blank_url_entries(
        self, tmp_path, monkeypatch
    ):
        """Blank-url entries are not shown by catalog list, so remove skips them too."""
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.parent.mkdir(parents=True, exist_ok=True)
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {"url": "   ", "name": "blank", "priority": 0},
                        {"url": "https://one.example.com/c.json", "name": "one"},
                        {"url": "https://two.example.com/c.json", "name": "two"},
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)

        removed = cat.remove_catalog(0)
        assert removed == "one"

        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        assert [c["name"] for c in data["catalogs"]] == ["blank", "two"]

    def test_remove_catalog_deletes_file_when_only_skipped_entries_remain(
        self, tmp_path, monkeypatch
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.parent.mkdir(parents=True, exist_ok=True)
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {"url": "   ", "name": "blank", "priority": 0},
                        {"url": "https://one.example.com/c.json", "name": "one"},
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)

        removed = cat.remove_catalog(0)
        assert removed == "one"
        assert not cfg_path.exists()

        active = cat.get_active_catalogs()
        assert [e.name for e in active] == ["default", "community"]

    def test_remove_catalog_allows_numeric_url_entry_cleanup(
        self, tmp_path, monkeypatch
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.parent.mkdir(parents=True, exist_ok=True)
        cfg_path.write_text(
            yaml.dump({"catalogs": [{"name": "numeric-url", "url": 123}]}),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)

        removed = cat.remove_catalog(0)

        assert removed == "numeric-url"
        assert not cfg_path.exists()

    def test_remove_catalog_errors_when_no_entries_are_removable(
        self, tmp_path, monkeypatch
    ):
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.parent.mkdir(parents=True, exist_ok=True)
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {"url": "", "name": "empty"},
                        {"name": "missing"},
                        "not-a-mapping",
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)

        with pytest.raises(
            IntegrationValidationError,
            match="no removable catalog entries",
        ):
            cat.remove_catalog(0)

    def test_remove_catalog_display_order_mixes_explicit_and_default(
        self, tmp_path, monkeypatch
    ):
        """An explicit low priority should sort ahead of default-priority
        siblings, even if it appears later in the YAML."""
        self._isolate(tmp_path, monkeypatch)
        cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
        cfg_path.parent.mkdir(parents=True, exist_ok=True)
        # Defaults: a=1, b=2 (implicit). Explicit c=0 → display: c, a, b.
        # The blank name should fall back to the removed URL, not raw YAML idx.
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {"url": "https://a.example.com/c.json", "name": "a"},
                        {"url": "https://b.example.com/c.json", "name": "b"},
                        {
                            "url": "https://c.example.com/c.json",
                            "name": "   ",
                            "priority": 0,
                        },
                    ]
                }
            ),
            encoding="utf-8",
        )
        cat = IntegrationCatalog(tmp_path)

        removed = cat.remove_catalog(0)
        assert removed == "https://c.example.com/c.json"

        data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        assert [c["name"] for c in data["catalogs"]] == ["a", "b"]
