import pytest
import yaml
from specify_cli.extensions import HookExecutor, ExtensionManifest

@pytest.fixture
def project_dir(tmp_path):
    """Create a mock spec-kit project directory."""
    proj_dir = tmp_path / "project"
    proj_dir.mkdir()
    (proj_dir / ".specify").mkdir()
    return proj_dir

class TestExtensionRegistration:
    """Tests for the 'installed' list management in HookExecutor."""

    def test_register_extension_new(self, project_dir):
        """Standard registration: Adding an extension should add it to the list."""
        executor = HookExecutor(project_dir)
        executor.register_extension("test-ext")
        
        config = executor.get_project_config()
        assert "installed" in config
        assert config["installed"] == ["test-ext"]

    def test_register_extension_sorting(self, project_dir):
        """Order Stability: Extensions should be stored in alphabetical order."""
        executor = HookExecutor(project_dir)
        executor.register_extension("zebra-ext")
        executor.register_extension("apple-ext")
        executor.register_extension("middle-ext")
        
        config = executor.get_project_config()
        assert config["installed"] == ["apple-ext", "middle-ext", "zebra-ext"]

    def test_register_extension_idempotency(self, project_dir):
        """Idempotency: Adding the same extension twice should not result in duplicates."""
        executor = HookExecutor(project_dir)
        executor.register_extension("test-ext")
        executor.register_extension("test-ext")
        
        config = executor.get_project_config()
        assert config["installed"] == ["test-ext"]
        assert len(config["installed"]) == 1

    def test_unregister_extension(self, project_dir):
        """Standard unregistration: Removing an extension should prune it from the list."""
        executor = HookExecutor(project_dir)
        executor.register_extension("ext-1")
        executor.register_extension("ext-2")
        
        executor.unregister_extension("ext-1")
        
        config = executor.get_project_config()
        assert config["installed"] == ["ext-2"]

    def test_unregister_extension_not_present(self, project_dir):
        """Safe Removal: Unregistering a non-existent extension should do nothing."""
        executor = HookExecutor(project_dir)
        executor.register_extension("ext-1")
        
        # Should not raise or change the list
        executor.unregister_extension("ext-nonexistent")
        
        config = executor.get_project_config()
        assert config["installed"] == ["ext-1"]

    def test_register_hooks_triggers_registration(self, project_dir, tmp_path):
        """Full Workflow: register_hooks should automatically register the extension."""
        # Create a mock manifest
        manifest_data = {
            "schema_version": "1.0",
            "extension": {
                "id": "hook-ext",
                "name": "Hook Ext",
                "version": "1.0.0",
                "description": "Test",
            },
            "requires": {
                "speckit_version": ">=0.1.0",
                "commands": []
            },
            "provides": {"commands": []},
            "hooks": {
                "after_tasks": {"command": "speckit.hook-ext.run"}
            }
        }
        manifest_path = tmp_path / "extension.yml"
        with open(manifest_path, "w") as f:
            yaml.dump(manifest_data, f)
        
        manifest = ExtensionManifest(manifest_path)
        executor = HookExecutor(project_dir)
        
        # This should call register_extension internally
        executor.register_hooks(manifest)
        
        config = executor.get_project_config()
        assert "hook-ext" in config["installed"]

    def test_missing_installed_key_initialization(self, project_dir):
        """Graceful Initialization: If 'installed' key is missing, it should be created."""
        executor = HookExecutor(project_dir)
        
        # Manually create a config without 'installed'
        config_path = project_dir / ".specify" / "extensions.yml"
        config_path.write_text(yaml.dump({"settings": {"auto_execute_hooks": True}}))
        
        # This should detect the missing key and initialize it
        executor.register_extension("new-ext")
        
        config = executor.get_project_config()
        assert "installed" in config
        assert config["installed"] == ["new-ext"]

    def test_unregister_hooks_full_workflow(self, project_dir, tmp_path):
        """Full Workflow: unregister_hooks should remove hooks and prune installed list."""
        # Create a manifest with hooks
        manifest_data = {
            "schema_version": "1.0",
            "extension": {
                "id": "hook-ext",
                "name": "Hook Ext",
                "version": "1.0.0",
                "description": "Test",
            },
            "requires": {
                "speckit_version": ">=0.1.0",
                "commands": []
            },
            "provides": {"commands": []},
            "hooks": {
                "after_tasks": {"command": "speckit.hook-ext.run"}
            }
        }
        manifest_path = tmp_path / "extension.yml"
        with open(manifest_path, "w") as f:
            yaml.dump(manifest_data, f)
        
        manifest = ExtensionManifest(manifest_path)
        executor = HookExecutor(project_dir)
        
        # Register hooks first
        executor.register_hooks(manifest)
        
        config = executor.get_project_config()
        assert "hook-ext" in config["installed"]
        assert "after_tasks" in config["hooks"]
        
        # Now unregister hooks
        executor.unregister_hooks("hook-ext")
        
        config = executor.get_project_config()
        assert "hook-ext" not in config["installed"]
        # unregister_hooks() removes the empty hook array entirely, so the key is absent
        assert "after_tasks" not in config["hooks"]

    def test_unregister_hooks_no_hooks_key(self, project_dir):
        """Resilience: unregister_hooks should work even if config has no 'hooks' key."""
        executor = HookExecutor(project_dir)
        
        # Register extension without hooks
        executor.register_extension("ext-no-hooks")
        
        config = executor.get_project_config()
        assert "ext-no-hooks" in config["installed"]
        
        # Unregister should not crash even if no hooks key exists
        executor.unregister_hooks("ext-no-hooks")
        
        config = executor.get_project_config()
        assert "ext-no-hooks" not in config["installed"]

    def test_unregister_hooks_corrupted_config(self, project_dir):
        """Resilience: unregister_hooks should gracefully handle corrupted config."""
        # Create a corrupted config (root is a list)
        config_path = project_dir / ".specify" / "extensions.yml"
        config_path.write_text(yaml.dump(["corrupted", "list"]))
        
        executor = HookExecutor(project_dir)
        
        # Should not raise even with corrupted config
        executor.unregister_hooks("non-existent")
        
        # Config should remain as-is or be handled gracefully
        config = executor.get_project_config()
        # If it's corrupted, it's returned as-is or handled by defensive logic
        assert config is not None

    def test_unregister_hooks_with_multiple_extensions(self, project_dir, tmp_path):
        """Multiple Extensions: unregister_hooks should only remove target extension's hooks."""
        # Create two manifests
        manifest_data_1 = {
            "schema_version": "1.0",
            "extension": {
                "id": "ext-1",
                "name": "Ext 1",
                "version": "1.0.0",
                "description": "Test 1",
            },
            "requires": {
                "speckit_version": ">=0.1.0",
                "commands": []
            },
            "provides": {"commands": []},
            "hooks": {
                "after_tasks": {"command": "speckit.ext-1.run"}
            }
        }
        manifest_data_2 = {
            "schema_version": "1.0",
            "extension": {
                "id": "ext-2",
                "name": "Ext 2",
                "version": "1.0.0",
                "description": "Test 2",
            },
            "requires": {
                "speckit_version": ">=0.1.0",
                "commands": []
            },
            "provides": {"commands": []},
            "hooks": {
                "after_tasks": {"command": "speckit.ext-2.run"}
            }
        }
        
        manifest_path_1 = tmp_path / "extension1.yml"
        manifest_path_2 = tmp_path / "extension2.yml"
        with open(manifest_path_1, "w") as f:
            yaml.dump(manifest_data_1, f)
        with open(manifest_path_2, "w") as f:
            yaml.dump(manifest_data_2, f)
        
        manifest1 = ExtensionManifest(manifest_path_1)
        manifest2 = ExtensionManifest(manifest_path_2)
        executor = HookExecutor(project_dir)
        
        # Register both extensions
        executor.register_hooks(manifest1)
        executor.register_hooks(manifest2)
        
        config = executor.get_project_config()
        assert "ext-1" in config["installed"]
        assert "ext-2" in config["installed"]
        assert len(config["hooks"]["after_tasks"]) == 2
        
        # Unregister first extension
        executor.unregister_hooks("ext-1")
        
        config = executor.get_project_config()
        assert "ext-1" not in config["installed"]
        assert "ext-2" in config["installed"]
        # ext-2's hook should still be there
        assert len(config["hooks"]["after_tasks"]) == 1
        assert config["hooks"]["after_tasks"][0].get("extension") == "ext-2"

    def test_register_hooks_no_hooks_still_registers(self, project_dir, tmp_path):
        """Commands-only manifest: register_hooks() must still update installed even with no hooks."""
        manifest_data = {
            "schema_version": "1.0",
            "extension": {
                "id": "commands-only-ext",
                "name": "Commands Only",
                "version": "1.0.0",
                "description": "No hooks, only commands",
            },
            "requires": {
                "speckit_version": ">=0.1.0",
                "commands": []
            },
            "provides": {"commands": [{"name": "speckit.commands-only-ext.run", "file": "commands/run.md"}]},
        }
        manifest_path = tmp_path / "extension.yml"
        with open(manifest_path, "w") as f:
            yaml.dump(manifest_data, f)

        manifest = ExtensionManifest(manifest_path)
        executor = HookExecutor(project_dir)
        executor.register_hooks(manifest)

        config = executor.get_project_config()
        assert "commands-only-ext" in config["installed"]

    def test_register_extension_mixed_type_installed(self, project_dir):
        """Regression: installed list with non-string entries must not crash on sort."""
        executor = HookExecutor(project_dir)

        # Manually write a corrupted installed list with non-string entries
        config_path = project_dir / ".specify" / "extensions.yml"
        config_path.write_text(yaml.dump({"installed": [1, True, "existing-ext"]}))

        # Should not raise TypeError on sort
        executor.register_extension("new-ext")

        config = executor.get_project_config()
        # Non-string entries are dropped; valid strings are preserved
        assert "existing-ext" in config["installed"]
        assert "new-ext" in config["installed"]
        assert 1 not in config["installed"]
        assert True not in config["installed"]

    def test_unregister_hooks_null_hook_values(self, project_dir):
        """Regression: hooks: {after_tasks: null} must not crash in unregister_hooks()."""
        executor = HookExecutor(project_dir)

        # Manually write a config with null hook event value
        config_path = project_dir / ".specify" / "extensions.yml"
        config_path.write_text(yaml.dump({
            "installed": ["broken-ext"],
            "hooks": {"after_tasks": None}
        }))

        # Should not raise TypeError when iterating None
        executor.unregister_hooks("broken-ext")

        config = executor.get_project_config()
        assert "broken-ext" not in config["installed"]

    def test_register_hooks_corrupted_hook_values(self, project_dir, tmp_path):
        """Regression: register_hooks() must handle non-list hook event values in config."""
        executor = HookExecutor(project_dir)

        # Manually write a config with null hook event value
        config_path = project_dir / ".specify" / "extensions.yml"
        config_path.write_text(yaml.dump({
            "installed": ["some-ext"],
            "hooks": {"after_tasks": None}
        }))

        # Create a manifest with a hook for the same event
        manifest_data = {
            "schema_version": "1.0",
            "extension": {
                "id": "new-ext",
                "name": "New Ext",
                "version": "1.0.0",
                "description": "Test",
            },
            "requires": {
                "speckit_version": ">=0.1.0",
                "commands": []
            },
            "provides": {"commands": []},
            "hooks": {"after_tasks": {"command": "speckit.new-ext.run"}}
        }
        manifest_path = tmp_path / "extension.yml"
        with open(manifest_path, "w") as f:
            yaml.dump(manifest_data, f)
        
        manifest = ExtensionManifest(manifest_path)
        
        # Should not raise TypeError when trying to append to None
        executor.register_hooks(manifest)

        config = executor.get_project_config()
        assert "new-ext" in config["installed"]
        assert isinstance(config["hooks"]["after_tasks"], list)
        assert any(h["extension"] == "new-ext" for h in config["hooks"]["after_tasks"])

    def test_register_extension_already_present_in_corrupted_list(self, project_dir):
        """Regression: if extension is already present but list has non-strings, it must still be sanitized."""
        executor = HookExecutor(project_dir)

        # Extension is present, but list has garbage
        config_path = project_dir / ".specify" / "extensions.yml"
        config_path.write_text(yaml.dump({"installed": [1, "test-ext", True]}))

        # This should trigger sanitization and save, even though "test-ext" is already there
        executor.register_extension("test-ext")

        config = executor.get_project_config()
        assert config["installed"] == ["test-ext"]
        # Verify it was actually saved to disk
        raw_config = yaml.safe_load(config_path.read_text())
        assert raw_config["installed"] == ["test-ext"]

    def test_register_extension_with_dict_entry(self, project_dir):
        """Review Feedback: register_extension should support and preserve dict entries."""
        executor = HookExecutor(project_dir)
        config_path = project_dir / ".specify" / "extensions.yml"
        
        # Setup config with a pinned extension (dict)
        pinned_ext = {"id": "pinned-ext", "version": "1.0.0"}
        config_path.write_text(yaml.dump({
            "installed": [pinned_ext, "string-ext"]
        }))
        
        # Register a new extension
        executor.register_extension("new-ext")
        
        config = executor.get_project_config()
        # Should contain all three, sorted by id: new-ext, pinned-ext, string-ext
        assert config["installed"] == ["new-ext", pinned_ext, "string-ext"]

    def test_unregister_extension_with_dict_entry(self, project_dir):
        """Review Feedback: unregister_extension should support removing matching dict entries."""
        executor = HookExecutor(project_dir)
        config_path = project_dir / ".specify" / "extensions.yml"
        
        pinned_ext = {"id": "to-remove", "version": "1.0.0"}
        config_path.write_text(yaml.dump({
            "installed": [pinned_ext, "other-ext"]
        }))
        
        # Unregister by ID
        executor.unregister_extension("to-remove")
        
        config = executor.get_project_config()
        assert config["installed"] == ["other-ext"]

    def test_unregister_extension_corrupted_installed(self, project_dir):
        """Hardening: unregister_extension should handle non-list installed key."""
        executor = HookExecutor(project_dir)
        config_path = project_dir / ".specify" / "extensions.yml"
        
        config_path.write_text(yaml.dump({
            "installed": "not-a-list"
        }))
        
        # Should not crash and should normalize to []
        executor.unregister_extension("any-ext")
        
        config = executor.get_project_config()
        assert config["installed"] == []
    def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path):
        """Regression: register_hooks() must sanitize hook event lists by dropping non-dicts."""
        executor = HookExecutor(project_dir)

        config_path = project_dir / ".specify" / "extensions.yml"
        config_path.write_text(yaml.dump({
            "installed": ["some-ext"],
            "hooks": {"after_tasks": [1, "corrupted", {"extension": "other", "command": "cmd"}]}
        }))

        manifest_path = tmp_path / "extension.yml"
        manifest_data = {
            "schema_version": "1.0",
            "extension": {
                "id": "new-ext",
                "name": "New Ext",
                "version": "1.0.0",
                "description": "Test",
                "author": "Test author"
            },
            "requires": {
                "speckit_version": ">=0.1.0",
                "commands": []
            },
            "provides": {"commands": []},
            "hooks": {
                "after_tasks": {"command": "new-cmd"}
            }
        }
        manifest_path.write_text(yaml.dump(manifest_data))
        manifest = ExtensionManifest(manifest_path)

        executor.register_hooks(manifest)

        config = executor.get_project_config()
        hooks = config["hooks"]["after_tasks"]
        
        # Should have 2 valid dict hooks, and 0 non-dict items
        assert len(hooks) == 2
        assert all(isinstance(h, dict) for h in hooks)
        assert any(h.get("extension") == "other" for h in hooks)
        assert any(h.get("extension") == "new-ext" for h in hooks)

    def test_unregister_extension_scalar_root(self, project_dir):
        """Hardening: unregister_extension should handle scalar root config."""
        executor = HookExecutor(project_dir)
        config_path = project_dir / ".specify" / "extensions.yml"
        
        config_path.write_text(yaml.dump(123))
        
        # Should not crash and should normalize to {}
        executor.unregister_extension("any-ext")
        
        config = executor.get_project_config()
        assert isinstance(config, dict)
        assert config["installed"] == []

    def test_unregister_hooks_scalar_hook_values(self, project_dir):
        """Regression: unregister_hooks() must handle scalar hook event values."""
        executor = HookExecutor(project_dir)
        config_path = project_dir / ".specify" / "extensions.yml"
        
        config_path.write_text(yaml.dump({
            "installed": ["some-ext"],
            "hooks": {"after_tasks": 123}
        }))
        
        # Should not raise TypeError when iterating
        executor.unregister_hooks("some-ext")
        
        config = executor.get_project_config()
        assert "some-ext" not in config["installed"]
        assert "after_tasks" not in config["hooks"]
