# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

from __future__ import annotations

import pytest

from airflow.sdk.api.datamodels._generated import ConnectionResponse
from airflow.sdk.exceptions import AirflowSecretsBackendAccessDenied, ErrorType
from airflow.sdk.execution_time.comms import ConnectionResult, ErrorResponse, VariableResult
from airflow.sdk.execution_time.secrets.execution_api import ExecutionAPISecretsBackend


class TestExecutionAPISecretsBackend:
    """Test ExecutionAPISecretsBackend."""

    def test_get_connection_via_supervisor_comms(self, mock_supervisor_comms):
        """Test that connection is retrieved via SUPERVISOR_COMMS."""
        conn_response = ConnectionResponse(
            conn_id="test_conn",
            conn_type="http",
            host="example.com",
            port=443,
            schema="https",
            login=None,
            password=None,
            extra=None,
        )
        conn_result = ConnectionResult.from_conn_response(conn_response)
        mock_supervisor_comms.send.return_value = conn_result

        backend = ExecutionAPISecretsBackend()
        conn = backend.get_connection("test_conn")

        assert conn is not None
        assert conn.conn_id == "test_conn"
        assert conn.conn_type == "http"
        assert conn.host == "example.com"
        mock_supervisor_comms.send.assert_called_once()

    def test_get_connection_not_found(self, mock_supervisor_comms):
        """Test that None is returned when connection not found."""
        error_response = ErrorResponse(error=ErrorType.CONNECTION_NOT_FOUND, detail={"message": "Not found"})
        mock_supervisor_comms.send.return_value = error_response

        backend = ExecutionAPISecretsBackend()
        conn = backend.get_connection("nonexistent")

        assert conn is None
        mock_supervisor_comms.send.assert_called_once()

    def test_get_variable_via_supervisor_comms(self, mock_supervisor_comms):
        """Test that variable is retrieved via SUPERVISOR_COMMS."""
        var_result = VariableResult(key="test_var", value="test_value")
        mock_supervisor_comms.send.return_value = var_result

        backend = ExecutionAPISecretsBackend()
        value = backend.get_variable("test_var")

        assert value == "test_value"
        mock_supervisor_comms.send.assert_called_once()

    def test_get_variable_not_found(self, mock_supervisor_comms):
        """Test that None is returned when variable not found."""
        error_response = ErrorResponse(error=ErrorType.VARIABLE_NOT_FOUND, detail={"message": "Not found"})
        mock_supervisor_comms.send.return_value = error_response

        backend = ExecutionAPISecretsBackend()
        value = backend.get_variable("nonexistent")

        assert value is None
        mock_supervisor_comms.send.assert_called_once()

    def test_get_connection_handles_exception(self, mock_supervisor_comms):
        """Test that exceptions are handled gracefully."""
        mock_supervisor_comms.send.side_effect = RuntimeError("Connection failed")

        backend = ExecutionAPISecretsBackend()
        conn = backend.get_connection("test_conn")

        # Should return None on exception to allow fallback to other backends
        assert conn is None

    def test_get_variable_handles_exception(self, mock_supervisor_comms):
        """Test that exceptions are handled gracefully for variables."""
        mock_supervisor_comms.send.side_effect = RuntimeError("Communication failed")

        backend = ExecutionAPISecretsBackend()
        value = backend.get_variable("test_var")

        # Should return None on exception to allow fallback to other backends
        assert value is None

    def test_get_conn_value_not_implemented(self):
        """Test that get_conn_value raises NotImplementedError."""
        backend = ExecutionAPISecretsBackend()
        with pytest.raises(NotImplementedError, match="Use get_connection instead"):
            backend.get_conn_value("test_conn")

    def test_get_connection_raises_on_permission_denied(self, mock_supervisor_comms):
        """An explicit deny from the Execution API must raise, not fall through.

        Returning None on a 401/403 would let the secrets-backend dispatcher
        fall through to a less-restrictive backend (e.g. EnvironmentVariablesBackend).
        """
        mock_supervisor_comms.send.return_value = ErrorResponse(
            error=ErrorType.PERMISSION_DENIED,
            detail={"conn_id": "denied_conn", "status_code": 403},
        )
        backend = ExecutionAPISecretsBackend()
        with pytest.raises(AirflowSecretsBackendAccessDenied, match="connection 'denied_conn'"):
            backend.get_connection("denied_conn")

    def test_get_variable_raises_on_permission_denied(self, mock_supervisor_comms):
        """An explicit deny from the Execution API must raise for variables too."""
        mock_supervisor_comms.send.return_value = ErrorResponse(
            error=ErrorType.PERMISSION_DENIED,
            detail={"key": "denied_var", "status_code": 403},
        )
        backend = ExecutionAPISecretsBackend()
        with pytest.raises(AirflowSecretsBackendAccessDenied, match="variable 'denied_var'"):
            backend.get_variable("denied_var")

    @pytest.mark.asyncio
    async def test_aget_connection_raises_on_permission_denied(self, mock_supervisor_comms):
        """Async variant must also raise on PERMISSION_DENIED."""

        async def asend(*_args, **_kwargs):
            return ErrorResponse(
                error=ErrorType.PERMISSION_DENIED,
                detail={"conn_id": "denied_conn", "status_code": 403},
            )

        mock_supervisor_comms.asend = asend
        backend = ExecutionAPISecretsBackend()
        with pytest.raises(AirflowSecretsBackendAccessDenied, match="connection 'denied_conn'"):
            await backend.aget_connection("denied_conn")

    @pytest.mark.asyncio
    async def test_aget_variable_raises_on_permission_denied(self, mock_supervisor_comms):
        """Async variant for variables must also raise on PERMISSION_DENIED."""

        async def asend(*_args, **_kwargs):
            return ErrorResponse(
                error=ErrorType.PERMISSION_DENIED,
                detail={"key": "denied_var", "status_code": 403},
            )

        mock_supervisor_comms.asend = asend
        backend = ExecutionAPISecretsBackend()
        with pytest.raises(AirflowSecretsBackendAccessDenied, match="variable 'denied_var'"):
            await backend.aget_variable("denied_var")


class TestDispatcherRefusesFallbackOnDeny:
    """End-to-end: the secrets-backend dispatcher must NOT fall through on an authoritative deny.

    A backend-level raise is not enough on its own — the outer ``except Exception:`` in
    ``context._get_connection`` / ``_get_variable`` / ``_async_get_connection`` previously
    swallowed ``PermissionError`` and silently called the next (less-restrictive) backend.
    These tests pin the dispatcher behaviour by inserting a spy backend AFTER
    ``ExecutionAPISecretsBackend`` and asserting it is never called once the first backend
    raises ``AirflowSecretsBackendAccessDenied``.
    """

    def test_get_connection_does_not_fall_through_after_deny(self, mock_supervisor_comms, monkeypatch):
        from unittest.mock import MagicMock

        from airflow.sdk.execution_time import context as ctx_module

        mock_supervisor_comms.send.return_value = ErrorResponse(
            error=ErrorType.PERMISSION_DENIED,
            detail={"conn_id": "denied_conn", "status_code": 403},
        )

        later_backend = MagicMock(name="LaterBackend")
        later_backend.get_connection.return_value = MagicMock(name="leaked_conn")

        monkeypatch.setattr(
            "airflow.sdk.execution_time.supervisor.ensure_secrets_backend_loaded",
            lambda: [ExecutionAPISecretsBackend(), later_backend],
        )

        with pytest.raises(AirflowSecretsBackendAccessDenied, match="connection 'denied_conn'"):
            ctx_module._get_connection("denied_conn")

        later_backend.get_connection.assert_not_called()

    def test_get_variable_does_not_fall_through_after_deny(self, mock_supervisor_comms, monkeypatch):
        from unittest.mock import MagicMock

        from airflow.sdk.execution_time import context as ctx_module

        mock_supervisor_comms.send.return_value = ErrorResponse(
            error=ErrorType.PERMISSION_DENIED,
            detail={"key": "denied_var", "status_code": 403},
        )

        later_backend = MagicMock(name="LaterBackend")
        later_backend.get_variable.return_value = "leaked-value"

        monkeypatch.setattr(
            "airflow.sdk.execution_time.supervisor.ensure_secrets_backend_loaded",
            lambda: [ExecutionAPISecretsBackend(), later_backend],
        )

        with pytest.raises(AirflowSecretsBackendAccessDenied, match="variable 'denied_var'"):
            ctx_module._get_variable("denied_var", deserialize_json=False)

        later_backend.get_variable.assert_not_called()

    @pytest.mark.asyncio
    async def test_async_get_connection_does_not_fall_through_after_deny(
        self, mock_supervisor_comms, monkeypatch
    ):
        from unittest.mock import MagicMock

        from airflow.sdk.execution_time import context as ctx_module

        async def asend(*_args, **_kwargs):
            return ErrorResponse(
                error=ErrorType.PERMISSION_DENIED,
                detail={"conn_id": "denied_conn", "status_code": 403},
            )

        mock_supervisor_comms.asend = asend

        later_backend = MagicMock(name="LaterBackend")
        # The dispatcher prefers aget_connection if present; mock both for safety.
        later_backend.aget_connection = MagicMock(return_value=MagicMock(name="leaked_conn"))
        later_backend.get_connection = MagicMock(return_value=MagicMock(name="leaked_conn"))

        monkeypatch.setattr(
            "airflow.sdk.execution_time.supervisor.ensure_secrets_backend_loaded",
            lambda: [ExecutionAPISecretsBackend(), later_backend],
        )

        with pytest.raises(AirflowSecretsBackendAccessDenied, match="connection 'denied_conn'"):
            await ctx_module._async_get_connection("denied_conn")

        later_backend.aget_connection.assert_not_called()
        later_backend.get_connection.assert_not_called()


class TestContextDetection:
    """Test context detection in ensure_secrets_backend_loaded."""

    def test_client_context_with_supervisor_comms(self, mock_supervisor_comms):
        """Client context: SUPERVISOR_COMMS set → uses worker chain."""
        from airflow.sdk.execution_time.supervisor import ensure_secrets_backend_loaded

        backends = ensure_secrets_backend_loaded()
        backend_classes = [type(b).__name__ for b in backends]
        assert "ExecutionAPISecretsBackend" in backend_classes
        assert "MetastoreBackend" not in backend_classes

    def test_server_context_with_env_var(self, monkeypatch):
        """Server context: env var set → uses server chain."""
        import sys

        from airflow.sdk.execution_time.supervisor import ensure_secrets_backend_loaded

        monkeypatch.setenv("_AIRFLOW_PROCESS_CONTEXT", "server")
        # Ensure SUPERVISOR_COMMS is not available
        if "airflow.sdk.execution_time.task_runner" in sys.modules:
            monkeypatch.delitem(sys.modules, "airflow.sdk.execution_time.task_runner")

        backends = ensure_secrets_backend_loaded()
        backend_classes = [type(b).__name__ for b in backends]
        assert "MetastoreBackend" in backend_classes
        assert "ExecutionAPISecretsBackend" not in backend_classes

    def test_fallback_context_no_markers(self, monkeypatch):
        """Fallback context: no SUPERVISOR_COMMS, no env var → only env vars + external."""
        import sys

        from airflow.sdk.execution_time.supervisor import ensure_secrets_backend_loaded

        # Ensure no SUPERVISOR_COMMS
        if "airflow.sdk.execution_time.task_runner" in sys.modules:
            monkeypatch.delitem(sys.modules, "airflow.sdk.execution_time.task_runner")

        # Ensure no env var
        monkeypatch.delenv("_AIRFLOW_PROCESS_CONTEXT", raising=False)

        backends = ensure_secrets_backend_loaded()
        backend_classes = [type(b).__name__ for b in backends]
        assert "EnvironmentVariablesBackend" in backend_classes
        assert "MetastoreBackend" not in backend_classes
        assert "ExecutionAPISecretsBackend" not in backend_classes
