import pytest
from unittest.mock import patch, MagicMock
from flask import Flask, Response
from mlflow_oidc_auth.hooks.before_request import (
    before_request_hook,
    _find_validator,
    _is_proxy_artifact_path,
    _get_proxy_artifact_validator,
    _re_compile_path,
    _stash_gateway_context,
    BEFORE_REQUEST_VALIDATORS,
    LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS,
)

app = Flask(__name__)
app.secret_key = "test_secret_key"


@pytest.fixture
def client():
    with app.test_client() as client:
        yield client


@pytest.fixture
def mock_bridge():
    with (
        patch(
            "mlflow_oidc_auth.hooks.before_request.get_fastapi_username",
            return_value="test_user",
        ) as mock_username,
        patch(
            "mlflow_oidc_auth.hooks.before_request.get_fastapi_admin_status",
            return_value=False,
        ) as mock_is_admin,
    ):
        yield mock_username, mock_is_admin


def test_before_request_hook_admin_bypass(client, mock_bridge):
    """Test that admin users bypass authorization"""
    with app.test_request_context(path="/protected", method="GET"):
        with patch(
            "mlflow_oidc_auth.hooks.before_request.get_fastapi_admin_status",
            return_value=True,
        ):
            response = before_request_hook()
            assert response is None  # Admin should bypass authorization


def test_before_request_hook_no_validator(client, mock_bridge):
    """Test when no validator is found for a route"""
    with app.test_request_context(path="/unknown/route", method="GET"):
        with (
            patch(
                "mlflow_oidc_auth.hooks.before_request._find_validator",
                return_value=None,
            ),
            patch(
                "mlflow_oidc_auth.hooks.before_request._is_proxy_artifact_path",
                return_value=False,
            ),
        ):
            response = before_request_hook()
            assert response is None  # No validator, so no authorization check


def test_before_request_hook_validator_success(client, mock_bridge):
    """Test successful authorization with validator"""
    mock_validator = MagicMock(return_value=True)

    with app.test_request_context(path="/protected", method="GET"):
        with (
            patch(
                "mlflow_oidc_auth.hooks.before_request._find_validator",
                return_value=mock_validator,
            ),
            patch(
                "mlflow_oidc_auth.hooks.before_request._is_proxy_artifact_path",
                return_value=False,
            ),
        ):
            response = before_request_hook()
            assert response is None  # Authorization succeeded
            mock_validator.assert_called_once_with("test_user")


def test_before_request_hook_validator_failure(client, mock_bridge):
    """Test authorization failure with validator"""
    mock_validator = MagicMock(return_value=False)

    with app.test_request_context(path="/protected", method="GET"):
        with (
            patch(
                "mlflow_oidc_auth.hooks.before_request._find_validator",
                return_value=mock_validator,
            ),
            patch(
                "mlflow_oidc_auth.hooks.before_request._is_proxy_artifact_path",
                return_value=False,
            ),
            patch(
                "mlflow_oidc_auth.hooks.before_request.responses.make_forbidden_response",
                return_value=Response("Forbidden", status=403),
            ) as mock_forbidden,
        ):
            response = before_request_hook()
            assert response.status_code == 403  # type: ignore
            mock_validator.assert_called_once_with("test_user")
            mock_forbidden.assert_called_once()


def test_find_validator_logged_models():
    """Test _find_validator for logged model routes"""
    mock_request = MagicMock()
    mock_request.path = "/api/2.0/mlflow/logged-models/12345"
    mock_request.method = "GET"

    mock_pattern = MagicMock()
    mock_pattern.fullmatch.return_value = True
    mock_validator = lambda: True

    with patch(
        "mlflow_oidc_auth.hooks.before_request.LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS",
        {(mock_pattern, "GET"): mock_validator},
    ):
        result = _find_validator(mock_request)
        assert result == mock_validator
        mock_pattern.fullmatch.assert_called_once_with("/api/2.0/mlflow/logged-models/12345")


def test_find_validator_logged_models_no_match():
    """Test _find_validator for logged model routes with no match"""
    mock_request = MagicMock()
    mock_request.path = "/api/2.0/mlflow/logged-models/12345"
    mock_request.method = "GET"

    mock_pattern = MagicMock()
    mock_pattern.fullmatch.return_value = False
    mock_validator = lambda: True

    with patch(
        "mlflow_oidc_auth.hooks.before_request.LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS",
        {(mock_pattern, "GET"): mock_validator},
    ):
        result = _find_validator(mock_request)
        assert result is None
        mock_pattern.fullmatch.assert_called_once_with("/api/2.0/mlflow/logged-models/12345")


def test_find_validator_regular_routes():
    """Test _find_validator for regular routes"""
    mock_request = MagicMock()
    mock_request.path = "/api/2.0/mlflow/experiments/create"
    mock_request.method = "POST"

    mock_validator = lambda: True

    with patch(
        "mlflow_oidc_auth.hooks.before_request.BEFORE_REQUEST_VALIDATORS",
        {("/api/2.0/mlflow/experiments/create", "POST"): mock_validator},
    ):
        result = _find_validator(mock_request)
        assert result == mock_validator


def test_find_validator_no_match():
    """Test _find_validator when no validator is found"""
    mock_request = MagicMock()
    mock_request.path = "/unknown/path"
    mock_request.method = "GET"

    with (
        patch("mlflow_oidc_auth.hooks.before_request.BEFORE_REQUEST_VALIDATORS", {}),
        patch(
            "mlflow_oidc_auth.hooks.before_request.LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS",
            {},
        ),
    ):
        result = _find_validator(mock_request)
        assert result is None


def test_re_compile_path():
    """Test _re_compile_path function"""
    # Test path with angle brackets
    pattern = _re_compile_path("/api/2.0/experiments/<experiment_id>")
    assert pattern.pattern == "/api/2.0/experiments/([^/]+)"

    # Test path without angle brackets
    pattern = _re_compile_path("/api/2.0/experiments/search")
    assert pattern.pattern == "/api/2.0/experiments/search"

    # Test path with multiple parameters
    pattern = _re_compile_path("/api/2.0/experiments/<experiment_id>/runs/<run_id>")
    assert pattern.pattern == "/api/2.0/experiments/([^/]+)/runs/([^/]+)"


def test_re_compile_path_matching():
    """Test that _re_compile_path creates working regex patterns"""
    pattern = _re_compile_path("/api/2.0/experiments/<experiment_id>")

    # Should match valid paths
    assert pattern.fullmatch("/api/2.0/experiments/123") is not None
    assert pattern.fullmatch("/api/2.0/experiments/abc-def") is not None

    # Should not match invalid paths
    assert pattern.fullmatch("/api/2.0/experiments/") is None
    assert pattern.fullmatch("/api/2.0/experiments/123/extra") is None
    assert pattern.fullmatch("/api/2.0/other/123") is None


def test_is_proxy_artifact_path():
    """Test _is_proxy_artifact_path function"""
    # Test positive case
    assert _is_proxy_artifact_path("/api/2.0/mlflow-artifacts/artifacts/experiment1/file.txt") is True

    # Test negative case
    assert _is_proxy_artifact_path("/api/2.0/mlflow/experiments/search") is False

    # Test edge cases
    assert _is_proxy_artifact_path("/api/2.0/mlflow-artifacts/artifacts/") is True
    assert _is_proxy_artifact_path("/api/2.0/mlflow-artifacts/other") is False


def test_get_proxy_artifact_validator_no_view_args():
    """Test _get_proxy_artifact_validator with no view_args (list operation)"""
    from mlflow_oidc_auth.validators import validate_can_read_experiment_artifact_proxy

    result = _get_proxy_artifact_validator("GET", None)
    assert result == validate_can_read_experiment_artifact_proxy


def test_get_proxy_artifact_validator_with_view_args():
    """Test _get_proxy_artifact_validator with view_args for different methods"""
    from mlflow_oidc_auth.validators import (
        validate_can_read_experiment_artifact_proxy,
        validate_can_update_experiment_artifact_proxy,
        validate_can_delete_experiment_artifact_proxy,
    )

    view_args = {"experiment_id": "123"}

    # Test GET (download)
    result = _get_proxy_artifact_validator("GET", view_args)
    assert result == validate_can_read_experiment_artifact_proxy

    # Test PUT (upload)
    result = _get_proxy_artifact_validator("PUT", view_args)
    assert result == validate_can_update_experiment_artifact_proxy

    # Test DELETE
    result = _get_proxy_artifact_validator("DELETE", view_args)
    assert result == validate_can_delete_experiment_artifact_proxy

    # Test unsupported method
    result = _get_proxy_artifact_validator("PATCH", view_args)
    assert result is None


def test_proxy_artifact_authorization_success(client, mock_bridge):
    """Test proxy artifact path authorization success"""
    with app.test_request_context(path="/api/2.0/mlflow-artifacts/artifacts/experiment1/file.txt", method="GET"):
        with (
            patch(
                "mlflow_oidc_auth.hooks.before_request._find_validator",
                return_value=None,
            ),
            patch(
                "mlflow_oidc_auth.hooks.before_request.validate_can_read_experiment_artifact_proxy",
                return_value=True,
            ) as mock_validator,
        ):
            response = before_request_hook()
            assert response is None  # Authorization succeeded
            mock_validator.assert_called_once_with("test_user")


def test_proxy_artifact_authorization_failure(client, mock_bridge):
    """Test proxy artifact path authorization failure"""
    with app.test_request_context(path="/api/2.0/mlflow-artifacts/artifacts/experiment1/file.txt", method="GET"):
        with (
            patch(
                "mlflow_oidc_auth.hooks.before_request._find_validator",
                return_value=None,
            ),
            patch(
                "mlflow_oidc_auth.hooks.before_request.validate_can_read_experiment_artifact_proxy",
                return_value=False,
            ) as mock_validator,
            patch(
                "mlflow_oidc_auth.hooks.before_request.responses.make_forbidden_response",
                return_value=Response("Forbidden", status=403),
            ) as mock_forbidden,
        ):
            response = before_request_hook()
            assert response.status_code == 403  # type: ignore
            mock_validator.assert_called_once_with("test_user")
            mock_forbidden.assert_called_once()


def test_proxy_artifact_no_validator(client, mock_bridge):
    """Test proxy artifact path when no validator is found"""
    with app.test_request_context(path="/api/2.0/mlflow-artifacts/artifacts/experiment1/file.txt", method="PATCH"):  # Unsupported method
        with patch(
            "mlflow_oidc_auth.hooks.before_request._get_proxy_artifact_validator",
            return_value=None,
        ):
            response = before_request_hook()
            assert response is None  # No validator, so no authorization check


def test_proxy_artifact_upload_authorization(client, mock_bridge):
    """Test proxy artifact path authorization for upload (PUT)"""
    with app.test_request_context(path="/api/2.0/mlflow-artifacts/artifacts/experiment1/file.txt", method="PUT"):
        # Mock request.view_args to simulate Flask route matching
        with patch("mlflow_oidc_auth.hooks.before_request.request") as mock_request:
            mock_request.path = "/api/2.0/mlflow-artifacts/artifacts/experiment1/file.txt"
            mock_request.method = "PUT"
            mock_request.view_args = {"experiment_id": "experiment1"}

            with (
                patch(
                    "mlflow_oidc_auth.hooks.before_request._find_validator",
                    return_value=None,
                ),
                patch(
                    "mlflow_oidc_auth.hooks.before_request.validate_can_update_experiment_artifact_proxy",
                    return_value=True,
                ) as mock_validator,
            ):
                response = before_request_hook()
                assert response is None  # Authorization succeeded
                mock_validator.assert_called_once_with("test_user")


def test_proxy_artifact_delete_authorization(client, mock_bridge):
    """Test proxy artifact path authorization for delete"""
    with app.test_request_context(path="/api/2.0/mlflow-artifacts/artifacts/experiment1/file.txt", method="DELETE"):
        # Mock request.view_args to simulate Flask route matching
        with patch("mlflow_oidc_auth.hooks.before_request.request") as mock_request:
            mock_request.path = "/api/2.0/mlflow-artifacts/artifacts/experiment1/file.txt"
            mock_request.method = "DELETE"
            mock_request.view_args = {"experiment_id": "experiment1"}

            with (
                patch(
                    "mlflow_oidc_auth.hooks.before_request._find_validator",
                    return_value=None,
                ),
                patch(
                    "mlflow_oidc_auth.hooks.before_request.validate_can_delete_experiment_artifact_proxy",
                    return_value=True,
                ) as mock_validator,
            ):
                response = before_request_hook()
                assert response is None  # Authorization succeeded
                mock_validator.assert_called_once_with("test_user")


def test_logged_model_route_authorization(client, mock_bridge):
    """Test authorization for logged model routes"""
    with app.test_request_context(path="/api/2.0/mlflow/logged-models/12345", method="GET"):
        mock_validator = MagicMock(return_value=True)

        with patch(
            "mlflow_oidc_auth.hooks.before_request._find_validator",
            return_value=mock_validator,
        ):
            response = before_request_hook()
            assert response is None  # Authorization succeeded
            mock_validator.assert_called_once_with("test_user")


def test_logged_model_route_authorization_failure(client, mock_bridge):
    """Test authorization failure for logged model routes"""
    with app.test_request_context(path="/api/2.0/mlflow/logged-models/12345", method="GET"):
        mock_validator = MagicMock(return_value=False)

        with (
            patch(
                "mlflow_oidc_auth.hooks.before_request._find_validator",
                return_value=mock_validator,
            ),
            patch(
                "mlflow_oidc_auth.hooks.before_request.responses.make_forbidden_response",
                return_value=Response("Forbidden", status=403),
            ) as mock_forbidden,
        ):
            response = before_request_hook()
            assert response.status_code == 403  # type: ignore
            mock_validator.assert_called_once_with("test_user")
            mock_forbidden.assert_called_once()


def test_before_request_hook_debug_logging(client, mock_bridge):
    """Test that debug logging is called with correct parameters"""
    with app.test_request_context(path="/test/path", method="POST"):
        with (
            patch("mlflow_oidc_auth.hooks.before_request.logger.debug") as mock_debug,
            patch(
                "mlflow_oidc_auth.hooks.before_request._find_validator",
                return_value=None,
            ),
            patch(
                "mlflow_oidc_auth.hooks.before_request._is_proxy_artifact_path",
                return_value=False,
            ),
        ):
            before_request_hook()
            mock_debug.assert_called_once_with("Before request hook called for path: /test/path, method: POST, username: test_user, is admin: False")


def test_before_request_hook_execution_order(client, mock_bridge):
    """Test that hook execution follows the correct order: admin check -> validator -> proxy artifact"""
    with app.test_request_context(path="/test/path", method="GET"):
        mock_validator = MagicMock(return_value=True)

        with (
            patch(
                "mlflow_oidc_auth.hooks.before_request.get_fastapi_admin_status",
                return_value=False,
            ) as mock_admin,
            patch(
                "mlflow_oidc_auth.hooks.before_request._find_validator",
                return_value=mock_validator,
            ) as mock_find_validator,
            patch(
                "mlflow_oidc_auth.hooks.before_request._is_proxy_artifact_path",
                return_value=False,
            ) as mock_is_proxy,
        ):
            before_request_hook()

            # Verify execution order by checking call order
            mock_admin.assert_called_once()
            mock_find_validator.assert_called_once()
            # _is_proxy_artifact_path should not be called since validator was found
            mock_is_proxy.assert_not_called()
            mock_validator.assert_called_once_with("test_user")


def test_before_request_hook_dependency_management(client, mock_bridge):
    """Test that hook properly manages dependencies between validators and proxy artifacts"""
    with app.test_request_context(path="/api/2.0/mlflow-artifacts/artifacts/exp1/file.txt", method="GET"):
        # When no regular validator is found, should check proxy artifacts
        with (
            patch(
                "mlflow_oidc_auth.hooks.before_request._find_validator",
                return_value=None,
            ) as mock_find_validator,
            patch(
                "mlflow_oidc_auth.hooks.before_request._is_proxy_artifact_path",
                return_value=True,
            ) as mock_is_proxy,
            patch(
                "mlflow_oidc_auth.hooks.before_request._get_proxy_artifact_validator",
                return_value=None,
            ) as mock_get_proxy_validator,
        ):
            response = before_request_hook()
            assert response is None  # No validator found, so no authorization check

            # Verify dependency chain
            mock_find_validator.assert_called_once()
            mock_is_proxy.assert_called_once_with("/api/2.0/mlflow-artifacts/artifacts/exp1/file.txt")
            mock_get_proxy_validator.assert_called_once_with("GET", None)


def test_logged_model_before_request_validators_structure():
    """Test that LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS has correct structure"""
    # Verify that the validators dictionary contains compiled regex patterns
    assert len(LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS) > 0

    for (pattern, method), validator in LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS.items():
        # Pattern should be a compiled regex
        assert hasattr(pattern, "fullmatch"), f"Pattern {pattern} should be a compiled regex"
        # Method should be a string
        assert isinstance(method, str), f"Method {method} should be a string"
        # Validator should be callable or None (some endpoints may not have validators)
        assert validator is None or callable(validator), f"Validator {validator} should be callable or None"


def test_before_request_validators_structure():
    """Test that BEFORE_REQUEST_VALIDATORS has correct structure"""
    # Verify that the validators dictionary has the expected structure
    assert len(BEFORE_REQUEST_VALIDATORS) > 0

    for (path, method), validator in BEFORE_REQUEST_VALIDATORS.items():
        # Path should be a string
        assert isinstance(path, str), f"Path {path} should be a string"
        # Method should be a string
        assert isinstance(method, str), f"Method {method} should be a string"
        # Validator should be callable or None (some endpoints may not have validators)
        assert validator is None or callable(validator), f"Validator {validator} should be callable or None"


# ---------------------------------------------------------------------------
# _stash_gateway_context tests
# ---------------------------------------------------------------------------


class TestStashGatewayContext:
    """Tests for _stash_gateway_context which stashes gateway resource info in flask.g."""

    def test_noop_when_validator_is_none(self):
        """No stashing occurs when no validator matches the request."""
        with app.test_request_context(path="/some/random/path", method="GET"):
            from flask import g

            _stash_gateway_context(None)
            assert not hasattr(g, "_updating_gateway_endpoint_old_name")
            assert not hasattr(g, "_deleting_gateway_endpoint_name")
            assert not hasattr(g, "_deleting_gateway_secret_name")
            assert not hasattr(g, "_deleting_gateway_model_definition_name")

    def test_stash_update_endpoint_old_name(self):
        """Stashes old endpoint name on update via endpoint_id resolution."""
        from mlflow_oidc_auth.validators.gateway import validate_can_update_gateway_endpoint

        with app.test_request_context(
            path="/api/3.0/mlflow/gateway/endpoints/update",
            method="POST",
            json={"endpoint_id": "ep-uuid-123", "name": "new-name"},
            content_type="application/json",
        ):
            from flask import g

            with patch(
                "mlflow_oidc_auth.validators.gateway._resolve_endpoint_name_from_id",
                return_value="old-endpoint-name",
            ):
                _stash_gateway_context(validate_can_update_gateway_endpoint)
                assert g._updating_gateway_endpoint_old_name == "old-endpoint-name"

    def test_stash_delete_endpoint_name(self):
        """Stashes endpoint name on delete via endpoint_id resolution."""
        from mlflow_oidc_auth.validators.gateway import validate_can_delete_gateway_endpoint

        with app.test_request_context(
            path="/api/3.0/mlflow/gateway/endpoints/delete",
            method="POST",
            json={"endpoint_id": "ep-uuid-456"},
            content_type="application/json",
        ):
            from flask import g

            with patch(
                "mlflow_oidc_auth.validators.gateway._resolve_endpoint_name_from_id",
                return_value="doomed-endpoint",
            ):
                _stash_gateway_context(validate_can_delete_gateway_endpoint)
                assert g._deleting_gateway_endpoint_name == "doomed-endpoint"

    def test_stash_delete_secret_name_from_field(self):
        """Stashes secret name on delete when secret_name is provided directly."""
        from mlflow_oidc_auth.validators.gateway import validate_can_delete_gateway_secret

        with app.test_request_context(
            path="/api/3.0/mlflow/gateway/secrets/delete",
            method="POST",
            json={"secret_name": "my-secret"},
            content_type="application/json",
        ):
            from flask import g

            _stash_gateway_context(validate_can_delete_gateway_secret)
            assert g._deleting_gateway_secret_name == "my-secret"

    def test_stash_delete_secret_name_from_id(self):
        """Stashes secret name on delete via secret_id resolution."""
        from mlflow_oidc_auth.validators.gateway import validate_can_delete_gateway_secret

        with app.test_request_context(
            path="/api/3.0/mlflow/gateway/secrets/delete",
            method="POST",
            json={"secret_id": "secret-uuid-789"},
            content_type="application/json",
        ):
            from flask import g

            with patch(
                "mlflow_oidc_auth.validators.gateway._resolve_secret_name_from_id",
                return_value="resolved-secret",
            ):
                _stash_gateway_context(validate_can_delete_gateway_secret)
                assert g._deleting_gateway_secret_name == "resolved-secret"

    def test_stash_delete_model_definition_name_from_field(self):
        """Stashes model definition name on delete when name is provided directly."""
        from mlflow_oidc_auth.validators.gateway import validate_can_delete_gateway_model_definition

        with app.test_request_context(
            path="/api/3.0/mlflow/gateway/model-definitions/delete",
            method="POST",
            json={"name": "my-model-def"},
            content_type="application/json",
        ):
            from flask import g

            _stash_gateway_context(validate_can_delete_gateway_model_definition)
            assert g._deleting_gateway_model_definition_name == "my-model-def"

    def test_stash_delete_model_definition_name_from_id(self):
        """Stashes model definition name on delete via model_definition_id resolution."""
        from mlflow_oidc_auth.validators.gateway import validate_can_delete_gateway_model_definition

        with app.test_request_context(
            path="/api/3.0/mlflow/gateway/model-definitions/delete",
            method="POST",
            json={"model_definition_id": "md-uuid-101"},
            content_type="application/json",
        ):
            from flask import g

            with patch(
                "mlflow_oidc_auth.validators.gateway._resolve_model_definition_name_from_id",
                return_value="resolved-model-def",
            ):
                _stash_gateway_context(validate_can_delete_gateway_model_definition)
                assert g._deleting_gateway_model_definition_name == "resolved-model-def"

    def test_noop_for_unrelated_validator(self):
        """No stashing for validators not related to gateway operations."""
        unrelated_validator = MagicMock()
        with app.test_request_context(path="/api/2.0/mlflow/experiments/get", method="GET"):
            from flask import g

            _stash_gateway_context(unrelated_validator)
            assert not hasattr(g, "_updating_gateway_endpoint_old_name")
            assert not hasattr(g, "_deleting_gateway_endpoint_name")
            assert not hasattr(g, "_deleting_gateway_secret_name")
            assert not hasattr(g, "_deleting_gateway_model_definition_name")
