# #region Test.GitPlugin [C:3] [TYPE Module] [SEMANTICS test, git, plugin, sync, deploy, helpers, coverage]
# @BRIEF Comprehensive tests covering GitPluginModule — helpers, execute, _handle_sync, _handle_deploy, _get_env, edge cases.
# @RELATION BINDS_TO -> [GitPluginModule]
# @TEST_EDGE: empty_zip -> Raises ValueError
# @TEST_EDGE: env_not_found -> Raises ValueError
# @TEST_EDGE: unknown_operation -> Raises ValueError
# @TEST_EDGE: backup_restore_on_failure -> Backup restored on unzip error
# @TEST_EDGE: deploy_missing_env_id -> Raises ValueError
# @TEST_EDGE: init_fallback -> Falls back to new ConfigManager when import fails
import asyncio
import io
import os
import shutil
import tempfile
import time
from pathlib import Path
import zipfile
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock

import pytest

from src.plugins.git_plugin import (
    _create_sync_backup, _delete_managed_files, _extract_zip_to_repo,
    _restore_sync_backup, _pack_deploy_zip, GitPlugin,
)


# ── Fixtures ──

@pytest.fixture
def temp_repo():
    """Create a temporary directory simulating a git repo."""
    tmp = tempfile.mkdtemp()
    yield Path(tmp)
    shutil.rmtree(tmp)


@pytest.fixture
def temp_backup():
    """Create a temporary backup directory."""
    tmp = tempfile.mkdtemp()
    yield Path(tmp)
    shutil.rmtree(tmp)


def _create_zip(files: dict[str, str], root_folder: str = "export") -> bytes:
    """Helper: create a ZIP file in memory with given file paths and contents."""
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        for path, content in files.items():
            zf.writestr(f"{root_folder}/{path}", content)
    buf.seek(0)
    return buf.getvalue()


def _make_plugin():
    """Helper: create GitPlugin with dependencies patched so config_manager is a mock."""
    mock_cm = MagicMock()
    mock_session = MagicMock()
    mock_session.query.return_value.filter.return_value.first.return_value = None
    # Also stub _ensure_base_path_exists to prevent filesystem access
    with patch('src.services.git._base.GitServiceBase._ensure_base_path_exists'), \
         patch('src.services.git._base.SessionLocal', return_value=mock_session), \
         patch('src.dependencies.config_manager', mock_cm):
        plugin = GitPlugin()
    return plugin, mock_cm


# ── Sync Helper Tests ──

class TestSyncHelpers:
    """Verify _sync_helpers functions comprehensively."""

    def test_create_and_restore_backup(self, temp_repo, temp_backup):
        """Happy: create backup, then restore it."""
        managed_dirs = ["dashboards", "charts"]
        managed_files = ["metadata.yaml"]

        # Create original files
        (temp_repo / "dashboards").mkdir(parents=True)
        (temp_repo / "dashboards" / "dash1.json").write_text("{'id': 1}")
        (temp_repo / "charts").mkdir(parents=True)
        (temp_repo / "charts" / "chart1.json").write_text("{'id': 2}")
        (temp_repo / "metadata.yaml").write_text("version: 1")

        # Create backup
        _create_sync_backup(temp_repo, temp_backup, managed_dirs, managed_files)
        assert (temp_backup / "dashboards" / "dash1.json").exists()
        assert (temp_backup / "charts" / "chart1.json").exists()
        assert (temp_backup / "metadata.yaml").exists()

        # Delete originals
        _delete_managed_files(temp_repo, managed_dirs, managed_files)
        assert not (temp_repo / "dashboards").exists()
        assert not (temp_repo / "metadata.yaml").exists()

        # Restore from backup
        _restore_sync_backup(temp_repo, temp_backup, managed_dirs, managed_files)
        assert (temp_repo / "dashboards" / "dash1.json").exists()
        assert (temp_repo / "metadata.yaml").exists()
        assert (temp_repo / "dashboards" / "dash1.json").read_text() == "{'id': 1}"

    def test_backup_non_existent_skips(self, temp_repo, temp_backup):
        """Edge: non-existent files skipped during backup."""
        _create_sync_backup(temp_repo, temp_backup, ["nonexistent"], ["no.yaml"])
        # No crash is success

    def test_backup_non_existent_file_skipped(self, temp_repo, temp_backup):
        """Edge: non-existent file path skipped during backup."""
        _create_sync_backup(temp_repo, temp_backup, [], ["no_such_file.yaml"])
        # No crash

    def test_delete_managed_files(self, temp_repo):
        """Happy: delete managed files."""
        (temp_repo / "dashboards").mkdir(parents=True)
        (temp_repo / "dashboards" / "dash1.json").write_text("{}")
        (temp_repo / "metadata.yaml").write_text("version: 1")

        _delete_managed_files(temp_repo, ["dashboards"], ["metadata.yaml"])
        assert not (temp_repo / "dashboards").exists()
        assert not (temp_repo / "metadata.yaml").exists()

    def test_delete_non_existent(self, temp_repo):
        """Edge: deleting non-existent paths is a no-op."""
        _delete_managed_files(temp_repo, ["does-not-exist"], ["no.yaml"])
        # No crash

    def test_delete_file_only(self, temp_repo):
        """Edge: delete only files, not dirs."""
        (temp_repo / "some_dir").mkdir(parents=True)
        (temp_repo / "some_dir" / "file.txt").write_text("data")
        (temp_repo / "metadata.yaml").write_text("version: 1")

        _delete_managed_files(temp_repo, [], ["metadata.yaml"])
        assert not (temp_repo / "metadata.yaml").exists()
        assert (temp_repo / "some_dir").exists()  # dirs untouched

    def test_extract_zip_to_repo(self, temp_repo):
        """Happy: extract zip to repository."""
        zip_bytes = _create_zip({
            "dashboards/dash1.json": '{"id": 1}',
            "charts/chart1.json": '{"id": 2}',
        })
        _extract_zip_to_repo(zip_bytes, temp_repo)
        assert (temp_repo / "dashboards" / "dash1.json").exists()
        assert (temp_repo / "charts" / "chart1.json").exists()
        assert (temp_repo / "dashboards" / "dash1.json").read_text() == '{"id": 1}'

    def test_extract_empty_zip_raises(self, temp_repo):
        """Negative: empty zip raises ValueError."""
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w") as zf:
            pass  # empty zip
        buf.seek(0)
        with pytest.raises(ValueError, match="empty"):
            _extract_zip_to_repo(buf.getvalue(), temp_repo)

    def test_extract_zip_binary_content(self, temp_repo):
        """Edge: extract zip with binary file content uses copyfileobj."""
        buf = io.BytesIO()
        raw_bytes = b'\x89PNG\r\n\x1a\nbinary data \x00\xff'
        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("export/images/logo.png", raw_bytes)
        buf.seek(0)
        _extract_zip_to_repo(buf.getvalue(), temp_repo)
        out = temp_repo / "images" / "logo.png"
        assert out.exists()
        assert out.read_bytes() == raw_bytes

    def test_extract_zip_nested_dirs(self, temp_repo):
        """Edge: extract zip with nested directory structure."""
        zip_bytes = _create_zip({
            "dashboards/sub/child.json": '{"nested": true}',
        })
        _extract_zip_to_repo(zip_bytes, temp_repo)
        assert (temp_repo / "dashboards" / "sub" / "child.json").exists()

    def test_restore_backup_non_existent(self, temp_repo, temp_backup):
        """Edge: restore when backup dirs don't exist."""
        _restore_sync_backup(temp_repo, temp_backup, ["missing"], ["no.yaml"])
        # No crash

    def test_restore_overwrites_existing(self, temp_repo, temp_backup):
        """Edge: restore overwrites existing files."""
        (temp_repo / "dashboards").mkdir(parents=True)
        (temp_repo / "dashboards" / "dash1.json").write_text("old")
        (temp_backup / "dashboards").mkdir(parents=True)
        (temp_backup / "dashboards" / "dash1.json").write_text("new")

        _restore_sync_backup(temp_repo, temp_backup, ["dashboards"], [])
        assert (temp_repo / "dashboards" / "dash1.json").read_text() == "new"

    def test_restore_backup_dir_with_file_in_subdir(self, temp_repo, temp_backup):
        """Edge: restore creates parent dirs for files."""
        (temp_backup / "subdir").mkdir(parents=True)
        (temp_backup / "subdir" / "deep.txt").write_text("deep")
        _restore_sync_backup(temp_repo, temp_backup, [], ["subdir/deep.txt"])
        assert (temp_repo / "subdir" / "deep.txt").exists()
        assert (temp_repo / "subdir" / "deep.txt").read_text() == "deep"


class TestDeployHelpers:
    """Verify _deploy_helpers functions comprehensively."""

    def test_pack_deploy_zip(self, temp_repo):
        """Happy: pack repo files into zip."""
        (temp_repo / "dashboards").mkdir(parents=True)
        (temp_repo / "dashboards" / "dash1.json").write_text('{"id": 1}')
        (temp_repo / "charts").mkdir(parents=True)
        (temp_repo / "charts" / "chart1.json").write_text('{"id": 2}')

        buf = io.BytesIO()
        _pack_deploy_zip(temp_repo, "export", buf)
        buf.seek(0)
        with zipfile.ZipFile(buf) as zf:
            names = zf.namelist()
            assert any("dash1.json" in n for n in names)
            assert any("chart1.json" in n for n in names)

    def test_pack_zip_skips_git(self, temp_repo):
        """Edge: .git directories excluded from zip."""
        (temp_repo / "dashboards").mkdir(parents=True)
        (temp_repo / "dashboards" / "dash1.json").write_text("{}")
        (temp_repo / ".git").mkdir(parents=True)
        (temp_repo / ".git" / "config").write_text("repo config")

        buf = io.BytesIO()
        _pack_deploy_zip(temp_repo, "export", buf)
        buf.seek(0)
        with zipfile.ZipFile(buf) as zf:
            names = zf.namelist()
            assert not any(n.startswith(".git") for n in names)
            assert any("dash1.json" in n for n in names)

    def test_pack_zip_empty_repo(self, temp_repo):
        """Edge: empty repo results in zip with root only."""
        buf = io.BytesIO()
        _pack_deploy_zip(temp_repo, "export", buf)
        buf.seek(0)
        with zipfile.ZipFile(buf) as zf:
            names = zf.namelist()
            assert len(names) == 0

    def test_pack_zip_skips_existing_zips(self, temp_repo):
        """Edge: .zip files excluded from deploy zip."""
        (temp_repo / "dashboards").mkdir(parents=True)
        (temp_repo / "dashboards" / "dash1.json").write_text("{}")
        (temp_repo / "deploy_1.zip").write_text("fake zip content")

        buf = io.BytesIO()
        _pack_deploy_zip(temp_repo, "export", buf)
        buf.seek(0)
        with zipfile.ZipFile(buf) as zf:
            names = zf.namelist()
            assert not any(n.endswith(".zip") for n in names)
            assert any("dash1.json" in n for n in names)

    def test_pack_zip_skips_git_files(self, temp_repo):
        """Edge: files named .git excluded."""
        (temp_repo / "dashboards").mkdir(parents=True)
        (temp_repo / "dashboards" / "dash1.json").write_text("{}")
        (temp_repo / ".git").write_text("file named .git")

        buf = io.BytesIO()
        _pack_deploy_zip(temp_repo, "export", buf)
        buf.seek(0)
        with zipfile.ZipFile(buf) as zf:
            names = zf.namelist()
            assert not any(".git" in n for n in names)


# ── GitPlugin Class Tests ──

class TestGitPluginInit:
    """Verify GitPlugin.__init__ behaviors."""

    def test_init_with_dependencies(self):
        """Happy: init fetches config_manager from src.dependencies."""
        mock_cm = MagicMock()
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.first.return_value = None
        with patch('src.services.git._base.GitServiceBase._ensure_base_path_exists'), \
             patch('src.services.git._base.SessionLocal', return_value=mock_session), \
             patch('src.dependencies.config_manager', mock_cm):
            plugin = GitPlugin()
            assert plugin.config_manager is mock_cm
            assert plugin.git_service is not None

    def test_init_fallback_config(self):
        """Negative: when import fails, falls back to new ConfigManager."""
        import sys
        import types
        saved = sys.modules.pop('src.dependencies', None)
        try:
            fake = types.ModuleType('src.dependencies')
            fake.__package__ = 'src'
            sys.modules['src.dependencies'] = fake
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = None
            with patch('src.services.git._base.GitServiceBase._ensure_base_path_exists'), \
                 patch('src.services.git._base.SessionLocal', return_value=mock_session), \
                 patch('src.plugins.git_plugin.ConfigManager') as MockCM:
                instance = MagicMock()
                MockCM.return_value = instance
                plugin = GitPlugin()
                assert plugin.config_manager is instance
        finally:
            if saved:
                sys.modules['src.dependencies'] = saved
            else:
                sys.modules.pop('src.dependencies', None)

    def test_init_retry_on_exception(self):
        """Edge: other exception during import also triggers fallback."""
        import sys
        import types
        saved = sys.modules.pop('src.dependencies', None)
        try:
            fake = types.ModuleType('src.dependencies')
            fake.__package__ = 'src'
            sys.modules['src.dependencies'] = fake
            mock_session = MagicMock()
            mock_session.query.return_value.filter.return_value.first.return_value = None
            with patch('src.services.git._base.GitServiceBase._ensure_base_path_exists'), \
                 patch('src.services.git._base.SessionLocal', return_value=mock_session), \
                 patch('src.plugins.git_plugin.ConfigManager') as MockCM:
                instance = MagicMock()
                MockCM.return_value = instance
                plugin = GitPlugin()
                assert plugin.config_manager is instance
        finally:
            if saved:
                sys.modules['src.dependencies'] = saved
            else:
                sys.modules.pop('src.dependencies', None)


class TestGitPluginProperties:
    """Verify static properties."""

    def test_properties(self):
        plugin, _ = _make_plugin()
        assert plugin.id == "git-integration"
        assert plugin.name == "Git Integration"
        assert plugin.description == "Version control for Superset dashboards"
        assert plugin.version == "0.1.0"
        assert plugin.ui_route == "/git"

    def test_get_schema(self):
        plugin, _ = _make_plugin()
        schema = plugin.get_schema()
        assert schema["type"] == "object"
        assert "operation" in schema["properties"]
        assert schema["properties"]["operation"]["enum"] == ["sync", "deploy", "history"]
        assert "dashboard_id" in schema["required"]
        assert "operation" in schema["required"]

    def test_initialize(self):
        plugin, _ = _make_plugin()
        asyncio.run(plugin.initialize())  # should not raise


class TestGitPluginExecute:
    """Verify GitPlugin.execute."""

    def test_unknown_operation_raises(self):
        plugin, _ = _make_plugin()
        with pytest.raises(ValueError, match="Unknown operation"):
            asyncio.run(plugin.execute({"operation": "unknown", "dashboard_id": 1}))

    def test_history_operation(self):
        plugin, _ = _make_plugin()
        result = asyncio.run(plugin.execute({"operation": "history", "dashboard_id": 1}))
        assert result["status"] == "success"

    @pytest.mark.asyncio
    async def test_sync_operation(self):
        plugin, _ = _make_plugin()
        with patch.object(GitPlugin, '_handle_sync', new=AsyncMock(return_value={"status": "success"})):
            result = await plugin.execute({"operation": "sync", "dashboard_id": 1})
            assert result["status"] == "success"

    @pytest.mark.asyncio
    async def test_deploy_operation(self):
        plugin, _ = _make_plugin()
        with patch.object(GitPlugin, '_handle_deploy', new=AsyncMock(return_value={"status": "success"})):
            result = await plugin.execute({"operation": "deploy", "dashboard_id": 1, "environment_id": "env-1"})
            assert result["status"] == "success"

    @pytest.mark.asyncio
    async def test_execute_with_task_context(self):
        """Edge: execute with TaskContext logging."""
        plugin, _ = _make_plugin()
        mock_ctx = MagicMock()
        mock_ctx.logger = MagicMock()
        mock_ctx.logger.with_source.return_value = MagicMock()

        with patch.object(GitPlugin, '_handle_sync', new=AsyncMock(return_value={"status": "success"})):
            result = await plugin.execute(
                {"operation": "sync", "dashboard_id": 1, "source_env_id": "src-env"},
                context=mock_ctx,
            )
            assert result["status"] == "success"

    def test_execute_no_operation_key(self):
        """Negative: missing operation key returns None branch."""
        plugin, _ = _make_plugin()
        with pytest.raises(ValueError, match="Unknown operation"):
            asyncio.run(plugin.execute({"dashboard_id": 1}))


def _mock_log():
    """Create a mock log object for _handle_sync/_handle_deploy."""
    log = MagicMock()
    log.with_source.return_value = log
    log.info = MagicMock()
    log.error = MagicMock()
    log.warning = MagicMock()
    log.debug = MagicMock()
    return log


class TestHandleSync:
    """Verify _handle_sync method."""

    @pytest.mark.asyncio
    async def test_handle_sync_success(self):
        """Happy: full sync flow succeeds."""
        plugin, mock_cm = _make_plugin()
        log = _mock_log()

        # Mock dependencies
        mock_env = MagicMock()
        mock_env.name = "Test Env"
        mock_cm.get_environment.return_value = mock_env

        with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
            # Mock git repo
            tmpdir = tempfile.mkdtemp()
            mock_repo = MagicMock()
            mock_repo.working_dir = tmpdir
            mock_repo.git = MagicMock()
            mock_repo.git.add = MagicMock()

            # Setup run_blocking side effects based on kind
            async def run_blocking_side(kind='file', fn=None, **kwargs):
                if kind == 'git' and fn == plugin.git_service.get_repo:
                    return mock_repo
                if kind == 'git' and fn == mock_repo.git.add:
                    return None
                if fn.__name__ == '_create_sync_backup':
                    return None
                if fn.__name__ == '_delete_managed_files':
                    return None
                if fn.__name__ == '_extract_zip_to_repo':
                    return None
                if fn.__name__ == 'rmtree':
                    return None
                return None

            mock_run_blocking.side_effect = run_blocking_side

            # Mock SupersetClient
            with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
                mock_client = MagicMock()
                MockSupersetClient.return_value = mock_client
                mock_client.authenticate = AsyncMock()
                mock_client.export_dashboard = AsyncMock(return_value=(b"zip data", "20240101"))

                result = await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)

                assert result["status"] == "success"
                assert "synced" in result["message"]

    @pytest.mark.asyncio
    async def test_handle_sync_cleanup_backup_on_success(self):
        """Edge: backup dir removed after successful sync."""
        plugin, mock_cm = _make_plugin()
        log = _mock_log()

        mock_env = MagicMock()
        mock_env.name = "Test"
        mock_cm.get_environment.return_value = mock_env

        with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
            mock_repo = MagicMock()
            mock_repo.working_dir = tempfile.mkdtemp()
            mock_repo.git = MagicMock()

            async def rb_side(kind='file', fn=None, **kwargs):
                if kind == 'git' and fn == plugin.git_service.get_repo:
                    return mock_repo
                if kind == 'git' and fn == mock_repo.git.add:
                    return None
                return None

            mock_run_blocking.side_effect = rb_side

            with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
                mock_client = MagicMock()
                MockSupersetClient.return_value = mock_client
                mock_client.authenticate = AsyncMock()
                mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "ts"))

                # Force backup_dir.exists() → True to trigger rmtree cleanup
                with patch('pathlib.Path.exists', return_value=True):
                    result = await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
                    assert result["status"] == "success"

    @pytest.mark.asyncio
    async def test_handle_sync_git_add_failure(self):
        """Edge: git add failure is logged but does not raise."""
        plugin, mock_cm = _make_plugin()
        log = _mock_log()

        mock_env = MagicMock()
        mock_env.name = "Test"
        mock_cm.get_environment.return_value = mock_env

        with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
            mock_repo = MagicMock()
            mock_repo.working_dir = tempfile.mkdtemp()
            mock_repo.git = MagicMock()
            mock_repo.git.add = MagicMock()

            call_count = 0

            async def rb_side(kind='file', fn=None, **kwargs):
                nonlocal call_count
                call_count += 1
                if kind == 'git' and fn == plugin.git_service.get_repo:
                    return mock_repo
                if kind == 'git' and fn == mock_repo.git.add:
                    raise RuntimeError("git add failed")
                return None

            mock_run_blocking.side_effect = rb_side

            with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
                mock_client = MagicMock()
                MockSupersetClient.return_value = mock_client
                mock_client.authenticate = AsyncMock()
                mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "ts"))

                result = await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
                assert result["status"] == "success"

    @pytest.mark.asyncio
    async def test_handle_sync_backup_restore_on_failure(self):
        """Negative: on unzip failure, backup is restored and error re-raised."""
        plugin, mock_cm = _make_plugin()
        log = _mock_log()

        mock_env = MagicMock()
        mock_env.name = "Test Env"
        mock_cm.get_environment.return_value = mock_env

        with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
            mock_repo = MagicMock()
            mock_repo.working_dir = tempfile.mkdtemp()
            mock_repo.git = MagicMock()

            async def run_blocking_side(kind='file', fn=None, **kwargs):
                if kind == 'git' and fn == plugin.git_service.get_repo:
                    return mock_repo
                if fn.__name__ == '_extract_zip_to_repo':
                    raise ValueError("Unzip failed")
                if fn.__name__ == '_restore_sync_backup':
                    return None
                return None

            mock_run_blocking.side_effect = run_blocking_side

            with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
                mock_client = MagicMock()
                MockSupersetClient.return_value = mock_client
                mock_client.authenticate = AsyncMock()
                mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "ts"))

                # backup_dir needs to exist for restore branch
                with patch('pathlib.Path.exists', return_value=True):
                    with pytest.raises(ValueError, match="Unzip failed"):
                        await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)

    @pytest.mark.asyncio
    async def test_handle_sync_dashboard_id_string(self):
        """Edge: sync with string dashboard_id."""
        plugin, mock_cm = _make_plugin()
        log = _mock_log()
        mock_env = MagicMock()
        mock_env.name = "Test"
        mock_cm.get_environment.return_value = mock_env

        with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
            mock_repo = MagicMock()
            mock_repo.working_dir = tempfile.mkdtemp()
            mock_repo.git = MagicMock()

            async def rb_side(kind='file', fn=None, **kwargs):
                if kind == 'git' and fn == plugin.git_service.get_repo:
                    return mock_repo
                return None

            mock_run_blocking.side_effect = rb_side

            with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
                mock_client = MagicMock()
                MockSupersetClient.return_value = mock_client
                mock_client.authenticate = AsyncMock()
                mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "ts"))

                result = await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
                assert result["status"] == "success"


class TestHandleDeploy:
    """Verify _handle_deploy method."""

    @pytest.mark.asyncio
    async def test_handle_deploy_success(self):
        """Happy: full deploy flow succeeds."""
        plugin, mock_cm = _make_plugin()
        log = _mock_log()

        mock_env = MagicMock()
        mock_env.name = "Target Env"
        mock_cm.get_environment.return_value = mock_env

        with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
            mock_repo = MagicMock()
            mock_repo.working_dir = tempfile.mkdtemp()
            mock_repo.git = MagicMock()

            async def rb_side(kind='file', fn=None, **kwargs):
                if kind == 'git' and fn == plugin.git_service.get_repo:
                    return mock_repo
                if fn.__name__ == '_pack_deploy_zip':
                    return None
                if fn == Path.write_bytes:
                    return None
                if fn == Path.exists:
                    return True
                if fn == os.remove:
                    return None
                return None

            mock_run_blocking.side_effect = rb_side

            with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
                mock_client = MagicMock()
                MockSupersetClient.return_value = mock_client
                mock_client.authenticate = AsyncMock()
                mock_client.import_dashboard = AsyncMock(return_value={"import": "ok"})

                result = await plugin._handle_deploy(1, "env-1", log=log, git_log=log, superset_log=log)
                assert result["status"] == "success"
                assert "deployed" in result["message"].lower()

    @pytest.mark.asyncio
    async def test_handle_deploy_cleanup_temp_zip(self):
        """Edge: temp zip is cleaned up in finally block after error."""
        plugin, mock_cm = _make_plugin()
        log = _mock_log()

        mock_env = MagicMock()
        mock_env.name = "Target"
        mock_cm.get_environment.return_value = mock_env

        with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
            mock_repo = MagicMock()
            mock_repo.working_dir = tempfile.mkdtemp()

            async def rb_side(kind='file', fn=None, **kwargs):
                if kind == 'git' and fn == plugin.git_service.get_repo:
                    return mock_repo
                if fn.__name__ == '_pack_deploy_zip':
                    return None
                if fn == Path.write_bytes:
                    return None
                if fn == Path.exists:
                    return True
                if fn == os.remove:
                    return None
                return None

            mock_run_blocking.side_effect = rb_side

            with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
                mock_client = MagicMock()
                MockSupersetClient.return_value = mock_client
                mock_client.authenticate = AsyncMock()
                mock_client.import_dashboard = AsyncMock(side_effect=RuntimeError("import failed"))

                with pytest.raises(RuntimeError, match="import failed"):
                    await plugin._handle_deploy(1, "env-1", log=log, git_log=log, superset_log=log)

    @pytest.mark.asyncio
    async def test_handle_deploy_missing_env_id(self):
        """Negative: empty env_id raises ValueError."""
        plugin, _ = _make_plugin()
        log = _mock_log()
        with pytest.raises(ValueError, match="Target environment ID required"):
            await plugin._handle_deploy(1, None, log=log, git_log=log, superset_log=log)

    @pytest.mark.asyncio
    async def test_handle_deploy_env_not_found(self):
        """Negative: env_id not in config manager."""
        plugin, mock_cm = _make_plugin()
        log = _mock_log()
        mock_cm.get_environment.return_value = None

        with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
            mock_repo = MagicMock()
            mock_repo.working_dir = tempfile.mkdtemp()

            async def rb_side(kind='file', fn=None, **kwargs):
                if kind == 'git' and fn == plugin.git_service.get_repo:
                    return mock_repo
                return None

            mock_run_blocking.side_effect = rb_side

            with pytest.raises(ValueError, match="not found"):
                await plugin._handle_deploy(1, "nonexistent-env", log=log, git_log=log, superset_log=log)


class TestGetEnv:
    """Verify _get_env method — all paths."""

    def test_get_env_from_config(self):
        """Happy: get env from ConfigManager."""
        plugin, mock_cm = _make_plugin()
        mock_env = MagicMock()
        mock_env.name = "Test Env"
        mock_env.id = "env-1"
        mock_cm.get_environment.return_value = mock_env

        result = plugin._get_env("env-1")
        assert result.name == "Test Env"

    def test_get_env_not_found_raises(self):
        """Negative: env not found in config or DB when env_id given."""
        from src.plugins.git_plugin import GitPlugin as RealGitPlugin
        mock_cm = MagicMock()
        mock_cm.get_environment.return_value = None
        mock_session = MagicMock()
        mock_session.query.return_value.filter.return_value.first.return_value = None
        with patch('src.services.git._base.GitServiceBase._ensure_base_path_exists'), \
             patch('src.services.git._base.SessionLocal', return_value=mock_session), \
             patch('src.dependencies.config_manager', mock_cm):
            plugin = RealGitPlugin()

        with patch('src.core.database.SessionLocal') as MockSession:
            mock_db = MagicMock()
            MockSession.return_value = mock_db
            db_filter = MagicMock()
            db_filter.first.return_value = None
            mock_db.query.return_value.filter.return_value = db_filter

            with pytest.raises(ValueError, match="not found"):
                plugin._get_env("bad-env")

    def test_get_env_db_fallback(self):
        """Happy: get env from DB when not in config."""
        plugin, mock_cm = _make_plugin()
        mock_cm.get_environment.return_value = None

        with patch('src.core.database.SessionLocal') as MockSession:
            mock_db = MagicMock()
            MockSession.return_value = mock_db
            mock_db_env = MagicMock()
            mock_db_env.id = "db-env-1"
            mock_db_env.name = "DB Env"
            mock_db_env.superset_url = "https://superset.example.com"
            mock_db_env.superset_token = "token123"

            db_filter = MagicMock()
            db_filter.first.return_value = mock_db_env
            mock_db.query.return_value.filter.return_value = db_filter

            with patch('src.core.config_models.Environment') as MockEnv:
                MockEnv.return_value.name = "DB Env"
                result = plugin._get_env("db-env-1")
                assert result.name == "DB Env"

    def test_get_env_first_active_db(self):
        """Happy: get first active env from DB when no env_id."""
        plugin, mock_cm = _make_plugin()
        mock_cm.get_environment.return_value = None

        with patch('src.core.database.SessionLocal') as MockSession:
            mock_db = MagicMock()
            MockSession.return_value = mock_db
            mock_db_env = MagicMock()
            mock_db_env.id = "active-1"
            mock_db_env.name = "Active Env"
            mock_db_env.superset_url = "https://example.com"
            mock_db_env.superset_token = "token"

            # First filter (is_active) returns the mock
            # Second filter (first env) never reached
            db_filter = MagicMock()
            db_filter.first.return_value = mock_db_env
            mock_db.query.return_value.filter.return_value = db_filter

            with patch('src.core.config_models.Environment'):
                result = plugin._get_env(None)
                assert result is not None

    def test_get_env_fallback_to_first_config(self):
        """Happy: fallback to first env from config when no env_id and no DB env."""
        plugin, mock_cm = _make_plugin()
        mock_cm.get_environment.return_value = None

        mock_env_list = [MagicMock(), MagicMock()]
        mock_env_list[0].name = "First Env"
        mock_cm.get_environments.return_value = mock_env_list

        with patch('src.core.database.SessionLocal') as MockSession:
            mock_db = MagicMock()
            MockSession.return_value = mock_db
            db_filter = MagicMock()
            db_filter.first.side_effect = [None, None]  # no active, no first
            mock_db.query.return_value.filter.return_value = db_filter
            mock_db.query.return_value.first.return_value = None  # plain .first() returns None

            result = plugin._get_env(None)
            assert result.name == "First Env"

    def test_get_env_no_envs_configured(self):
        """Negative: no environments at all raises ValueError."""
        plugin, mock_cm = _make_plugin()
        mock_cm.get_environment.return_value = None
        mock_cm.get_environments.return_value = []

        with patch('src.core.database.SessionLocal') as MockSession:
            mock_db = MagicMock()
            MockSession.return_value = mock_db
            db_filter = MagicMock()
            db_filter.first.side_effect = [None, None]
            mock_db.query.return_value.filter.return_value = db_filter
            mock_db.query.return_value.first.return_value = None  # plain .first() returns None

            with pytest.raises(ValueError, match="No environments configured"):
                plugin._get_env(None)

    def test_get_env_cm_config_id_present(self):
        """Edge: env_id given but not found in config, but found in DB."""
        plugin, mock_cm = _make_plugin()
        mock_cm.get_environment.return_value = None

        with patch('src.core.database.SessionLocal') as MockSession:
            mock_db = MagicMock()
            MockSession.return_value = mock_db
            mock_db_env = MagicMock()
            mock_db_env.id = "db-1"
            mock_db_env.name = "DB Found"
            mock_db_env.superset_url = "https://example.com"
            mock_db_env.superset_token = "tok"

            db_filter = MagicMock()
            db_filter.first.return_value = mock_db_env
            mock_db.query.return_value.filter.return_value = db_filter

            with patch('src.core.config_models.Environment') as MockEnv:
                MockEnv.return_value.name = "DB Found"
                result = plugin._get_env("db-1")
                assert result.name == "DB Found"
# #endregion Test.GitPlugin
