from io import BytesIO
from unittest.mock import MagicMock, patch

import pytest
from flask import Flask
from werkzeug.datastructures import FileStorage

from services.trigger.webhook_service import WebhookService


class TestWebhookServiceUnit:
    """Unit tests for WebhookService focusing on business logic without database dependencies."""

    def test_extract_webhook_data_json(self):
        """Test webhook data extraction from JSON request."""
        app = Flask(__name__)

        with app.test_request_context(
            "/webhook",
            method="POST",
            headers={"Content-Type": "application/json", "Authorization": "Bearer token"},
            query_string="version=1&format=json",
            json={"message": "hello", "count": 42},
        ):
            webhook_trigger = MagicMock()
            webhook_data = WebhookService.extract_webhook_data(webhook_trigger)

            assert webhook_data["method"] == "POST"
            assert webhook_data["headers"]["Authorization"] == "Bearer token"
            # Query params are now extracted as raw strings
            assert webhook_data["query_params"]["version"] == "1"
            assert webhook_data["query_params"]["format"] == "json"
            assert webhook_data["body"]["message"] == "hello"
            assert webhook_data["body"]["count"] == 42
            assert webhook_data["files"] == {}

    def test_extract_webhook_data_query_params_remain_strings(self):
        """Query parameters should be extracted as raw strings without automatic conversion."""
        app = Flask(__name__)

        with app.test_request_context(
            "/webhook",
            method="GET",
            headers={"Content-Type": "application/json"},
            query_string="count=42&threshold=3.14&enabled=true&note=text",
        ):
            webhook_trigger = MagicMock()
            webhook_data = WebhookService.extract_webhook_data(webhook_trigger)

            # After refactoring, raw extraction keeps query params as strings
            assert webhook_data["query_params"]["count"] == "42"
            assert webhook_data["query_params"]["threshold"] == "3.14"
            assert webhook_data["query_params"]["enabled"] == "true"
            assert webhook_data["query_params"]["note"] == "text"

    def test_extract_webhook_data_form_urlencoded(self):
        """Test webhook data extraction from form URL encoded request."""
        app = Flask(__name__)

        with app.test_request_context(
            "/webhook",
            method="POST",
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            data={"username": "test", "password": "secret"},
        ):
            webhook_trigger = MagicMock()
            webhook_data = WebhookService.extract_webhook_data(webhook_trigger)

            assert webhook_data["method"] == "POST"
            assert webhook_data["body"]["username"] == "test"
            assert webhook_data["body"]["password"] == "secret"

    def test_extract_webhook_data_multipart_with_files(self):
        """Test webhook data extraction from multipart form with files."""
        app = Flask(__name__)

        # Create a mock file
        file_content = b"test file content"
        file_storage = FileStorage(stream=BytesIO(file_content), filename="test.txt", content_type="text/plain")

        with app.test_request_context(
            "/webhook",
            method="POST",
            headers={"Content-Type": "multipart/form-data"},
            data={"message": "test", "file": file_storage},
        ):
            webhook_trigger = MagicMock()
            webhook_trigger.tenant_id = "test_tenant"

            with patch.object(WebhookService, "_process_file_uploads", autospec=True) as mock_process_files:
                mock_process_files.return_value = {"file": "mocked_file_obj"}

                webhook_data = WebhookService.extract_webhook_data(webhook_trigger)

                assert webhook_data["method"] == "POST"
                assert webhook_data["body"]["message"] == "test"
                assert webhook_data["files"]["file"] == "mocked_file_obj"
                mock_process_files.assert_called_once()

    def test_extract_webhook_data_raw_text(self):
        """Test webhook data extraction from raw text request."""
        app = Flask(__name__)

        with app.test_request_context(
            "/webhook", method="POST", headers={"Content-Type": "text/plain"}, data="raw text content"
        ):
            webhook_trigger = MagicMock()
            webhook_data = WebhookService.extract_webhook_data(webhook_trigger)

            assert webhook_data["method"] == "POST"
            assert webhook_data["body"]["raw"] == "raw text content"

    def test_extract_octet_stream_body_uses_detected_mime(self):
        """Octet-stream uploads should rely on detected MIME type."""
        app = Flask(__name__)
        binary_content = b"plain text data"

        with app.test_request_context(
            "/webhook", method="POST", headers={"Content-Type": "application/octet-stream"}, data=binary_content
        ):
            webhook_trigger = MagicMock()
            mock_file = MagicMock()
            mock_file.to_dict.return_value = {"file": "data"}

            with (
                patch.object(
                    WebhookService, "_detect_binary_mimetype", return_value="text/plain", autospec=True
                ) as mock_detect,
                patch.object(WebhookService, "_create_file_from_binary", autospec=True) as mock_create,
            ):
                mock_create.return_value = mock_file
                body, files = WebhookService._extract_octet_stream_body(webhook_trigger)

            assert body["raw"] == {"file": "data"}
            assert files == {}
            mock_detect.assert_called_once_with(binary_content)
            mock_create.assert_called_once()
            args = mock_create.call_args[0]
            assert args[0] == binary_content
            assert args[1] == "text/plain"
            assert args[2] is webhook_trigger

    def test_detect_binary_mimetype_uses_magic(self, monkeypatch: pytest.MonkeyPatch):
        """python-magic output should be used when available."""
        fake_magic = MagicMock()
        fake_magic.from_buffer.return_value = "image/png"
        monkeypatch.setattr("services.trigger.webhook_service.magic", fake_magic)

        result = WebhookService._detect_binary_mimetype(b"binary data")

        assert result == "image/png"
        fake_magic.from_buffer.assert_called_once()

    def test_detect_binary_mimetype_fallback_without_magic(self, monkeypatch: pytest.MonkeyPatch):
        """Fallback MIME type should be used when python-magic is unavailable."""
        monkeypatch.setattr("services.trigger.webhook_service.magic", None)

        result = WebhookService._detect_binary_mimetype(b"binary data")

        assert result == "application/octet-stream"

    def test_detect_binary_mimetype_handles_magic_exception(self, monkeypatch: pytest.MonkeyPatch):
        """Fallback MIME type should be used when python-magic raises an exception."""
        try:
            import magic as real_magic
        except ImportError:
            pytest.skip("python-magic is not installed")

        fake_magic = MagicMock()
        fake_magic.from_buffer.side_effect = real_magic.MagicException("magic error")
        monkeypatch.setattr("services.trigger.webhook_service.magic", fake_magic)

        with patch("services.trigger.webhook_service.logger", autospec=True) as mock_logger:
            result = WebhookService._detect_binary_mimetype(b"binary data")

            assert result == "application/octet-stream"
            mock_logger.debug.assert_called_once()

    def test_extract_webhook_data_invalid_json(self):
        """Test webhook data extraction with invalid JSON."""
        app = Flask(__name__)

        with app.test_request_context(
            "/webhook", method="POST", headers={"Content-Type": "application/json"}, data="invalid json"
        ):
            webhook_trigger = MagicMock()
            with pytest.raises(ValueError, match="Invalid JSON body"):
                WebhookService.extract_webhook_data(webhook_trigger)

    def test_generate_webhook_response_default(self):
        """Test webhook response generation with default values."""
        node_config = {"data": {}}

        response_data, status_code = WebhookService.generate_webhook_response(node_config)

        assert status_code == 200
        assert response_data["status"] == "success"
        assert "Webhook processed successfully" in response_data["message"]

    def test_generate_webhook_response_custom_json(self):
        """Test webhook response generation with custom JSON response."""
        node_config = {"data": {"status_code": 201, "response_body": '{"result": "created", "id": 123}'}}

        response_data, status_code = WebhookService.generate_webhook_response(node_config)

        assert status_code == 201
        assert response_data["result"] == "created"
        assert response_data["id"] == 123

    def test_generate_webhook_response_custom_text(self):
        """Test webhook response generation with custom text response."""
        node_config = {"data": {"status_code": 202, "response_body": "Request accepted for processing"}}

        response_data, status_code = WebhookService.generate_webhook_response(node_config)

        assert status_code == 202
        assert response_data["message"] == "Request accepted for processing"

    def test_generate_webhook_response_invalid_json(self):
        """Test webhook response generation with invalid JSON response."""
        node_config = {"data": {"status_code": 400, "response_body": '{"invalid": json}'}}

        response_data, status_code = WebhookService.generate_webhook_response(node_config)

        assert status_code == 400
        assert response_data["message"] == '{"invalid": json}'

    def test_generate_webhook_response_empty_response_body(self):
        """Test webhook response generation with empty response body."""
        node_config = {"data": {"status_code": 204, "response_body": ""}}

        response_data, status_code = WebhookService.generate_webhook_response(node_config)

        assert status_code == 204
        assert response_data["status"] == "success"
        assert "Webhook processed successfully" in response_data["message"]

    def test_generate_webhook_response_array_json(self):
        """Test webhook response generation with JSON array response."""
        node_config = {"data": {"status_code": 200, "response_body": '[{"id": 1}, {"id": 2}]'}}

        response_data, status_code = WebhookService.generate_webhook_response(node_config)

        assert status_code == 200
        assert isinstance(response_data, list)
        assert len(response_data) == 2
        assert response_data[0]["id"] == 1
        assert response_data[1]["id"] == 2

    @patch("services.trigger.webhook_service.ToolFileManager", autospec=True)
    @patch("services.trigger.webhook_service.file_factory", autospec=True)
    def test_process_file_uploads_success(self, mock_file_factory, mock_tool_file_manager):
        """Test successful file upload processing."""
        # Mock ToolFileManager
        mock_tool_file_instance = mock_tool_file_manager.return_value  # Mock file creation
        mock_tool_file = MagicMock()
        mock_tool_file.id = "test_file_id"
        mock_tool_file_instance.create_file_by_raw.return_value = mock_tool_file

        # Mock file factory
        mock_file_obj = MagicMock()
        mock_file_factory.build_from_mapping.return_value = mock_file_obj

        # Create mock files
        files = {
            "file1": MagicMock(filename="test1.txt", content_type="text/plain"),
            "file2": MagicMock(filename="test2.jpg", content_type="image/jpeg"),
        }

        # Mock file reads
        files["file1"].read.return_value = b"content1"
        files["file2"].read.return_value = b"content2"

        webhook_trigger = MagicMock()
        webhook_trigger.tenant_id = "test_tenant"

        result = WebhookService._process_file_uploads(files, webhook_trigger)

        assert len(result) == 2
        assert "file1" in result
        assert "file2" in result

        # Verify file processing was called for each file
        assert mock_tool_file_manager.call_count == 2
        assert mock_file_factory.build_from_mapping.call_count == 2

    @patch("services.trigger.webhook_service.ToolFileManager", autospec=True)
    @patch("services.trigger.webhook_service.file_factory", autospec=True)
    def test_process_file_uploads_with_errors(self, mock_file_factory, mock_tool_file_manager):
        """Test file upload processing with errors."""
        # Mock ToolFileManager
        mock_tool_file_instance = mock_tool_file_manager.return_value  # Mock file creation
        mock_tool_file = MagicMock()
        mock_tool_file.id = "test_file_id"
        mock_tool_file_instance.create_file_by_raw.return_value = mock_tool_file

        # Mock file factory
        mock_file_obj = MagicMock()
        mock_file_factory.build_from_mapping.return_value = mock_file_obj

        # Create mock files, one will fail
        files = {
            "good_file": MagicMock(filename="test.txt", content_type="text/plain"),
            "bad_file": MagicMock(filename="test.bad", content_type="text/plain"),
        }

        files["good_file"].read.return_value = b"content"
        files["bad_file"].read.side_effect = Exception("Read error")

        webhook_trigger = MagicMock()
        webhook_trigger.tenant_id = "test_tenant"

        result = WebhookService._process_file_uploads(files, webhook_trigger)

        # Should process the good file and skip the bad one
        assert len(result) == 1
        assert "good_file" in result
        assert "bad_file" not in result

    def test_process_file_uploads_empty_filename(self):
        """Test file upload processing with empty filename."""
        files = {
            "no_filename": MagicMock(filename="", content_type="text/plain"),
            "none_filename": MagicMock(filename=None, content_type="text/plain"),
        }

        webhook_trigger = MagicMock()
        webhook_trigger.tenant_id = "test_tenant"

        result = WebhookService._process_file_uploads(files, webhook_trigger)

        # Should skip files without filenames
        assert len(result) == 0

    def test_validate_json_value_string(self):
        """Test JSON value validation for string type."""
        # Valid string
        result = WebhookService._validate_json_value("name", "hello", "string")
        assert result == "hello"

        # Invalid string (number) - should raise ValueError
        with pytest.raises(ValueError, match="Expected string, got int"):
            WebhookService._validate_json_value("name", 123, "string")

    def test_validate_json_value_number(self):
        """Test JSON value validation for number type."""
        # Valid integer
        result = WebhookService._validate_json_value("count", 42, "number")
        assert result == 42

        # Valid float
        result = WebhookService._validate_json_value("price", 19.99, "number")
        assert result == 19.99

        # Invalid number (string) - should raise ValueError
        with pytest.raises(ValueError, match="Expected number, got str"):
            WebhookService._validate_json_value("count", "42", "number")

    def test_validate_json_value_bool(self):
        """Test JSON value validation for boolean type."""
        # Valid boolean
        result = WebhookService._validate_json_value("enabled", True, "boolean")
        assert result is True

        result = WebhookService._validate_json_value("enabled", False, "boolean")
        assert result is False

        # Invalid boolean (string) - should raise ValueError
        with pytest.raises(ValueError, match="Expected boolean, got str"):
            WebhookService._validate_json_value("enabled", "true", "boolean")

    def test_validate_json_value_object(self):
        """Test JSON value validation for object type."""
        # Valid object
        result = WebhookService._validate_json_value("user", {"name": "John", "age": 30}, "object")
        assert result == {"name": "John", "age": 30}

        # Invalid object (string) - should raise ValueError
        with pytest.raises(ValueError, match="Expected object, got str"):
            WebhookService._validate_json_value("user", "not_an_object", "object")

    def test_validate_json_value_array_string(self):
        """Test JSON value validation for array[string] type."""
        # Valid array of strings
        result = WebhookService._validate_json_value("tags", ["tag1", "tag2", "tag3"], "array[string]")
        assert result == ["tag1", "tag2", "tag3"]

        # Invalid - not an array
        with pytest.raises(ValueError, match="Expected array of strings, got str"):
            WebhookService._validate_json_value("tags", "not_an_array", "array[string]")

        # Invalid - array with non-strings
        with pytest.raises(ValueError, match="Expected array of strings, got list"):
            WebhookService._validate_json_value("tags", ["tag1", 123, "tag3"], "array[string]")

    def test_validate_json_value_array_number(self):
        """Test JSON value validation for array[number] type."""
        # Valid array of numbers
        result = WebhookService._validate_json_value("scores", [1, 2.5, 3, 4.7], "array[number]")
        assert result == [1, 2.5, 3, 4.7]

        # Invalid - array with non-numbers
        with pytest.raises(ValueError, match="Expected array of numbers, got list"):
            WebhookService._validate_json_value("scores", [1, "2", 3], "array[number]")

    def test_validate_json_value_array_bool(self):
        """Test JSON value validation for array[boolean] type."""
        # Valid array of booleans
        result = WebhookService._validate_json_value("flags", [True, False, True], "array[boolean]")
        assert result == [True, False, True]

        # Invalid - array with non-booleans
        with pytest.raises(ValueError, match="Expected array of booleans, got list"):
            WebhookService._validate_json_value("flags", [True, "false", True], "array[boolean]")

    def test_validate_json_value_array_object(self):
        """Test JSON value validation for array[object] type."""
        # Valid array of objects
        result = WebhookService._validate_json_value("users", [{"name": "John"}, {"name": "Jane"}], "array[object]")
        assert result == [{"name": "John"}, {"name": "Jane"}]

        # Invalid - array with non-objects
        with pytest.raises(ValueError, match="Expected array of objects, got list"):
            WebhookService._validate_json_value("users", [{"name": "John"}, "not_object"], "array[object]")

    def test_convert_form_value_string(self):
        """Test form value conversion for string type."""
        result = WebhookService._convert_form_value("test", "hello", "string")
        assert result == "hello"

    def test_convert_form_value_number(self):
        """Test form value conversion for number type."""
        # Integer
        result = WebhookService._convert_form_value("count", "42", "number")
        assert result == 42

        # Float
        result = WebhookService._convert_form_value("price", "19.99", "number")
        assert result == 19.99

        # Invalid number
        with pytest.raises(ValueError, match="Cannot convert 'not_a_number' to number"):
            WebhookService._convert_form_value("count", "not_a_number", "number")

    def test_convert_form_value_boolean(self):
        """Test form value conversion for boolean type."""
        # True values
        assert WebhookService._convert_form_value("flag", "true", "boolean") is True
        assert WebhookService._convert_form_value("flag", "1", "boolean") is True
        assert WebhookService._convert_form_value("flag", "yes", "boolean") is True

        # False values
        assert WebhookService._convert_form_value("flag", "false", "boolean") is False
        assert WebhookService._convert_form_value("flag", "0", "boolean") is False
        assert WebhookService._convert_form_value("flag", "no", "boolean") is False

        # Invalid boolean
        with pytest.raises(ValueError, match="Cannot convert 'maybe' to boolean"):
            WebhookService._convert_form_value("flag", "maybe", "boolean")

    def test_extract_and_validate_webhook_data_success(self):
        """Test successful unified data extraction and validation."""
        app = Flask(__name__)

        with app.test_request_context(
            "/webhook",
            method="POST",
            headers={"Content-Type": "application/json"},
            query_string="count=42&enabled=true",
            json={"message": "hello", "age": 25},
        ):
            webhook_trigger = MagicMock()
            node_config = {
                "data": {
                    "method": "post",
                    "content_type": "application/json",
                    "params": [
                        {"name": "count", "type": "number", "required": True},
                        {"name": "enabled", "type": "boolean", "required": True},
                    ],
                    "body": [
                        {"name": "message", "type": "string", "required": True},
                        {"name": "age", "type": "number", "required": True},
                    ],
                }
            }

            result = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)

            # Check that types are correctly converted
            assert result["query_params"]["count"] == 42  # Converted to int
            assert result["query_params"]["enabled"] is True  # Converted to bool
            assert result["body"]["message"] == "hello"  # Already string
            assert result["body"]["age"] == 25  # Already number

    def test_extract_and_validate_webhook_data_invalid_json_error(self):
        """Invalid JSON should bubble up as a ValueError with details."""
        app = Flask(__name__)

        with app.test_request_context(
            "/webhook",
            method="POST",
            headers={"Content-Type": "application/json"},
            data='{"invalid": }',
        ):
            webhook_trigger = MagicMock()
            node_config = {
                "data": {
                    "method": "post",
                    "content_type": "application/json",
                }
            }

            with pytest.raises(ValueError, match="Invalid JSON body"):
                WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)

    def test_extract_and_validate_webhook_data_validation_error(self):
        """Test unified data extraction with validation error."""
        app = Flask(__name__)

        with app.test_request_context(
            "/webhook",
            method="GET",  # Wrong method
            headers={"Content-Type": "application/json"},
        ):
            webhook_trigger = MagicMock()
            node_config = {
                "data": {
                    "method": "post",  # Expects POST
                    "content_type": "application/json",
                }
            }

            with pytest.raises(ValueError, match="HTTP method mismatch"):
                WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)

    def test_debug_mode_parameter_handling(self):
        """Test that the debug mode parameter is properly handled in _prepare_webhook_execution."""
        from controllers.trigger.webhook import _prepare_webhook_execution

        # Mock the WebhookService methods
        with (
            patch.object(WebhookService, "get_webhook_trigger_and_workflow", autospec=True) as mock_get_trigger,
            patch.object(WebhookService, "extract_and_validate_webhook_data", autospec=True) as mock_extract,
        ):
            mock_trigger = MagicMock()
            mock_workflow = MagicMock()
            mock_config = {"data": {"test": "config"}}
            mock_data = {"test": "data"}

            mock_get_trigger.return_value = (mock_trigger, mock_workflow, mock_config)
            mock_extract.return_value = mock_data

            result = _prepare_webhook_execution("test_webhook", is_debug=False)
            assert result == (mock_trigger, mock_workflow, mock_config, mock_data, None)

            # Reset mock
            mock_get_trigger.reset_mock()

            result = _prepare_webhook_execution("test_webhook", is_debug=True)
            assert result == (mock_trigger, mock_workflow, mock_config, mock_data, None)


# === Merged from test_webhook_service_additional.py ===


from types import SimpleNamespace
from typing import Any, cast

from werkzeug.exceptions import RequestEntityTooLarge

from core.workflow.nodes.trigger_webhook.entities import (
    ContentType,
    WebhookBodyParameter,
    WebhookData,
    WebhookParameter,
)
from graphon.variables.types import SegmentType
from models.enums import AppTriggerStatus
from models.model import App
from models.trigger import WorkflowWebhookTrigger
from models.workflow import Workflow
from services.errors.app import QuotaExceededError
from services.trigger import webhook_service as service_module


class _FakeQuery:
    def __init__(self, result: Any) -> None:
        self._result = result

    def where(self, *args: Any, **kwargs: Any) -> "_FakeQuery":
        return self

    def filter(self, *args: Any, **kwargs: Any) -> "_FakeQuery":
        return self

    def order_by(self, *args: Any, **kwargs: Any) -> "_FakeQuery":
        return self

    def first(self) -> Any:
        return self._result


class _SessionContext:
    def __init__(self, session: Any) -> None:
        self._session = session

    def __enter__(self) -> Any:
        return self._session

    def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
        return False


class _SessionmakerContext:
    def __init__(self, session: Any) -> None:
        self._session = session

    def begin(self) -> "_SessionmakerContext":
        return self

    def __enter__(self) -> Any:
        return self._session

    def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
        return False


@pytest.fixture
def flask_app() -> Flask:
    return Flask(__name__)


def _patch_session(monkeypatch: pytest.MonkeyPatch, session: Any) -> None:
    monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=MagicMock(), session=MagicMock()))
    monkeypatch.setattr(service_module, "Session", lambda *args, **kwargs: _SessionContext(session))
    monkeypatch.setattr(service_module, "sessionmaker", lambda *args, **kwargs: _SessionmakerContext(session))


def _workflow_trigger(**kwargs: Any) -> WorkflowWebhookTrigger:
    return cast(WorkflowWebhookTrigger, SimpleNamespace(**kwargs))


def _workflow(**kwargs: Any) -> Workflow:
    return cast(Workflow, SimpleNamespace(**kwargs))


def _app(**kwargs: Any) -> App:
    return cast(App, SimpleNamespace(**kwargs))


def test_get_webhook_trigger_and_workflow_should_raise_when_webhook_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    fake_session = MagicMock()
    fake_session.scalar.return_value = None
    _patch_session(monkeypatch, fake_session)

    # Act / Assert
    with pytest.raises(ValueError, match="Webhook not found"):
        WebhookService.get_webhook_trigger_and_workflow("webhook-1")


def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_not_found(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1")
    fake_session = MagicMock()
    fake_session.scalar.side_effect = [webhook_trigger, None]
    _patch_session(monkeypatch, fake_session)

    # Act / Assert
    with pytest.raises(ValueError, match="App trigger not found"):
        WebhookService.get_webhook_trigger_and_workflow("webhook-1")


def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_rate_limited(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1")
    app_trigger = SimpleNamespace(status=AppTriggerStatus.RATE_LIMITED)
    fake_session = MagicMock()
    fake_session.scalar.side_effect = [webhook_trigger, app_trigger]
    _patch_session(monkeypatch, fake_session)

    # Act / Assert
    with pytest.raises(ValueError, match="rate limited"):
        WebhookService.get_webhook_trigger_and_workflow("webhook-1")


def test_get_webhook_trigger_and_workflow_should_raise_when_app_trigger_disabled(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1")
    app_trigger = SimpleNamespace(status=AppTriggerStatus.DISABLED)
    fake_session = MagicMock()
    fake_session.scalar.side_effect = [webhook_trigger, app_trigger]
    _patch_session(monkeypatch, fake_session)

    # Act / Assert
    with pytest.raises(ValueError, match="disabled"):
        WebhookService.get_webhook_trigger_and_workflow("webhook-1")


def test_get_webhook_trigger_and_workflow_should_raise_when_workflow_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1")
    app_trigger = SimpleNamespace(status=AppTriggerStatus.ENABLED)
    fake_session = MagicMock()
    fake_session.scalar.side_effect = [webhook_trigger, app_trigger, None]
    _patch_session(monkeypatch, fake_session)

    # Act / Assert
    with pytest.raises(ValueError, match="Workflow not found"):
        WebhookService.get_webhook_trigger_and_workflow("webhook-1")


def test_get_webhook_trigger_and_workflow_should_return_values_for_non_debug_mode(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1")
    app_trigger = SimpleNamespace(status=AppTriggerStatus.ENABLED)
    workflow = MagicMock()
    workflow.get_node_config_by_id.return_value = {"data": {"key": "value"}}

    fake_session = MagicMock()
    fake_session.scalar.side_effect = [webhook_trigger, app_trigger, workflow]
    _patch_session(monkeypatch, fake_session)

    # Act
    got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow("webhook-1")

    # Assert
    assert got_trigger is webhook_trigger
    assert got_workflow is workflow
    assert got_node_config == {"data": {"key": "value"}}


def test_get_webhook_trigger_and_workflow_should_return_values_for_debug_mode(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    webhook_trigger = SimpleNamespace(app_id="app-1", node_id="node-1")
    workflow = MagicMock()
    workflow.get_node_config_by_id.return_value = {"data": {"mode": "debug"}}

    fake_session = MagicMock()
    fake_session.scalar.side_effect = [webhook_trigger, workflow]
    _patch_session(monkeypatch, fake_session)

    # Act
    got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow(
        "webhook-1", is_debug=True
    )

    # Assert
    assert got_trigger is webhook_trigger
    assert got_workflow is workflow
    assert got_node_config == {"data": {"mode": "debug"}}


def test_extract_webhook_data_should_use_text_fallback_for_unknown_content_type(
    flask_app: Flask,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    warning_mock = MagicMock()
    monkeypatch.setattr(service_module.logger, "warning", warning_mock)
    webhook_trigger = MagicMock()

    # Act
    with flask_app.test_request_context(
        "/webhook",
        method="POST",
        headers={"Content-Type": "application/vnd.custom"},
        data="plain content",
    ):
        result = WebhookService.extract_webhook_data(webhook_trigger)

    # Assert
    assert result["body"] == {"raw": "plain content"}
    warning_mock.assert_called_once()


def test_extract_webhook_data_should_raise_for_request_too_large(
    flask_app: Flask,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    monkeypatch.setattr(service_module.dify_config, "WEBHOOK_REQUEST_BODY_MAX_SIZE", 1)

    # Act / Assert
    with flask_app.test_request_context("/webhook", method="POST", data="ab"):
        with pytest.raises(RequestEntityTooLarge):
            WebhookService.extract_webhook_data(MagicMock())


def test_extract_octet_stream_body_should_return_none_when_empty_payload(flask_app: Flask) -> None:
    # Arrange
    webhook_trigger = MagicMock()

    # Act
    with flask_app.test_request_context("/webhook", method="POST", data=b""):
        body, files = WebhookService._extract_octet_stream_body(webhook_trigger)

    # Assert
    assert body == {"raw": None}
    assert files == {}


def test_extract_octet_stream_body_should_return_none_when_processing_raises(
    flask_app: Flask,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    webhook_trigger = MagicMock()
    monkeypatch.setattr(WebhookService, "_detect_binary_mimetype", MagicMock(return_value="application/octet-stream"))
    monkeypatch.setattr(WebhookService, "_create_file_from_binary", MagicMock(side_effect=RuntimeError("boom")))

    # Act
    with flask_app.test_request_context("/webhook", method="POST", data=b"abc"):
        body, files = WebhookService._extract_octet_stream_body(webhook_trigger)

    # Assert
    assert body == {"raw": None}
    assert files == {}


def test_extract_text_body_should_return_empty_string_when_request_read_fails(
    flask_app: Flask,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    monkeypatch.setattr("flask.wrappers.Request.get_data", MagicMock(side_effect=RuntimeError("read error")))

    # Act
    with flask_app.test_request_context("/webhook", method="POST", data="abc"):
        body, files = WebhookService._extract_text_body()

    # Assert
    assert body == {"raw": ""}
    assert files == {}


def test_detect_binary_mimetype_should_fallback_when_magic_raises(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    fake_magic = MagicMock()
    fake_magic.from_buffer.side_effect = RuntimeError("magic failed")
    monkeypatch.setattr(service_module, "magic", fake_magic)

    # Act
    result = WebhookService._detect_binary_mimetype(b"binary")

    # Assert
    assert result == "application/octet-stream"


def test_process_file_uploads_should_use_octet_stream_fallback_when_mimetype_unknown(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    webhook_trigger = _workflow_trigger(created_by="user-1", tenant_id="tenant-1")
    file_obj = MagicMock()
    file_obj.to_dict.return_value = {"id": "f-1"}
    monkeypatch.setattr(WebhookService, "_create_file_from_binary", MagicMock(return_value=file_obj))
    monkeypatch.setattr(service_module.mimetypes, "guess_type", MagicMock(return_value=(None, None)))

    uploaded = MagicMock()
    uploaded.filename = "file.unknown"
    uploaded.content_type = None
    uploaded.read.return_value = b"content"

    # Act
    result = WebhookService._process_file_uploads({"f": uploaded}, webhook_trigger)

    # Assert
    assert result == {"f": {"id": "f-1"}}


def test_create_file_from_binary_should_call_tool_file_manager_and_file_factory(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    webhook_trigger = _workflow_trigger(created_by="user-1", tenant_id="tenant-1")
    manager = MagicMock()
    manager.create_file_by_raw.return_value = SimpleNamespace(id="tool-file-1")
    monkeypatch.setattr(service_module, "ToolFileManager", MagicMock(return_value=manager))
    expected_file = MagicMock()
    monkeypatch.setattr(service_module.file_factory, "build_from_mapping", MagicMock(return_value=expected_file))

    # Act
    result = WebhookService._create_file_from_binary(b"abc", "text/plain", webhook_trigger)

    # Assert
    assert result is expected_file
    manager.create_file_by_raw.assert_called_once()


@pytest.mark.parametrize(
    ("raw_value", "param_type", "expected"),
    [
        ("42", SegmentType.NUMBER, 42),
        ("3.14", SegmentType.NUMBER, 3.14),
        ("yes", SegmentType.BOOLEAN, True),
        ("no", SegmentType.BOOLEAN, False),
    ],
)
def test_convert_form_value_should_convert_supported_types(
    raw_value: str,
    param_type: str,
    expected: Any,
) -> None:
    # Arrange

    # Act
    result = WebhookService._convert_form_value("param", raw_value, param_type)

    # Assert
    assert result == expected


def test_convert_form_value_should_raise_for_unsupported_type() -> None:
    # Arrange

    # Act / Assert
    with pytest.raises(ValueError, match="Unsupported type"):
        WebhookService._convert_form_value("p", "x", SegmentType.FILE)


def test_validate_json_value_should_return_original_for_unmapped_supported_segment_type(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    warning_mock = MagicMock()
    monkeypatch.setattr(service_module.logger, "warning", warning_mock)

    # Act
    result = WebhookService._validate_json_value("param", {"x": 1}, "unsupported-type")

    # Assert
    assert result == {"x": 1}
    warning_mock.assert_called_once()


def test_validate_and_convert_value_should_wrap_conversion_errors() -> None:
    # Arrange

    # Act / Assert
    with pytest.raises(ValueError, match="validation failed"):
        WebhookService._validate_and_convert_value("param", "bad", SegmentType.NUMBER, is_form_data=True)


def test_process_parameters_should_raise_when_required_parameter_missing() -> None:
    # Arrange
    raw_params = {"optional": "x"}
    config = [WebhookParameter(name="required_param", type=SegmentType.STRING, required=True)]

    # Act / Assert
    with pytest.raises(ValueError, match="Required parameter missing"):
        WebhookService._process_parameters(raw_params, config, is_form_data=True)


def test_process_parameters_should_include_unconfigured_parameters() -> None:
    # Arrange
    raw_params = {"known": "1", "unknown": "x"}
    config = [WebhookParameter(name="known", type=SegmentType.NUMBER, required=False)]

    # Act
    result = WebhookService._process_parameters(raw_params, config, is_form_data=True)

    # Assert
    assert result == {"known": 1, "unknown": "x"}


def test_process_body_parameters_should_raise_when_required_text_raw_is_missing() -> None:
    # Arrange

    # Act / Assert
    with pytest.raises(ValueError, match="Required body content missing"):
        WebhookService._process_body_parameters(
            raw_body={"raw": ""},
            body_configs=[WebhookBodyParameter(name="raw", required=True)],
            content_type=ContentType.TEXT,
        )


def test_process_body_parameters_should_skip_file_config_for_multipart_form_data() -> None:
    # Arrange
    raw_body = {"message": "hello", "extra": "x"}
    body_configs = [
        WebhookBodyParameter(name="upload", type=SegmentType.FILE, required=True),
        WebhookBodyParameter(name="message", type=SegmentType.STRING, required=True),
    ]

    # Act
    result = WebhookService._process_body_parameters(raw_body, body_configs, ContentType.FORM_DATA)

    # Assert
    assert result == {"message": "hello", "extra": "x"}


def test_validate_required_headers_should_accept_sanitized_header_names() -> None:
    # Arrange
    headers = {"x_api_key": "123"}
    configs = [WebhookParameter(name="x-api-key", required=True)]

    # Act
    WebhookService._validate_required_headers(headers, configs)

    # Assert
    assert True


def test_validate_required_headers_should_raise_when_required_header_missing() -> None:
    # Arrange
    headers = {"x-other": "123"}
    configs = [WebhookParameter(name="x-api-key", required=True)]

    # Act / Assert
    with pytest.raises(ValueError, match="Required header missing"):
        WebhookService._validate_required_headers(headers, configs)


def test_validate_http_metadata_should_return_content_type_mismatch_error() -> None:
    # Arrange
    webhook_data = {"method": "POST", "headers": {"Content-Type": "application/json"}}
    node_data = WebhookData(method="post", content_type=ContentType.TEXT)

    # Act
    result = WebhookService._validate_http_metadata(webhook_data, node_data)

    # Assert
    assert result["valid"] is False
    assert "Content-type mismatch" in result["error"]


def test_extract_content_type_should_fallback_to_lowercase_header_key() -> None:
    # Arrange
    headers = {"content-type": "application/json; charset=utf-8"}

    # Act
    result = WebhookService._extract_content_type(headers)

    # Assert
    assert result == "application/json"


def test_build_workflow_inputs_should_include_expected_keys() -> None:
    # Arrange
    webhook_data = {"headers": {"h": "v"}, "query_params": {"q": 1}, "body": {"b": 2}}

    # Act
    result = WebhookService.build_workflow_inputs(webhook_data)

    # Assert
    assert result["webhook_data"] == webhook_data
    assert result["webhook_headers"] == {"h": "v"}
    assert result["webhook_query_params"] == {"q": 1}
    assert result["webhook_body"] == {"b": 2}


def test_trigger_workflow_execution_should_trigger_async_workflow_successfully(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    webhook_trigger = _workflow_trigger(
        app_id="app-1",
        node_id="node-1",
        tenant_id="tenant-1",
        webhook_id="webhook-1",
    )
    workflow = _workflow(id="wf-1")
    webhook_data = {"body": {"x": 1}}

    session = MagicMock()
    _patch_session(monkeypatch, session)

    end_user = SimpleNamespace(id="end-user-1")
    monkeypatch.setattr(
        service_module.EndUserService, "get_or_create_end_user_by_type", MagicMock(return_value=end_user)
    )
    quota_type = SimpleNamespace(TRIGGER=SimpleNamespace(consume=MagicMock()))
    monkeypatch.setattr(service_module, "QuotaType", quota_type)
    trigger_async_mock = MagicMock()
    monkeypatch.setattr(service_module.AsyncWorkflowService, "trigger_workflow_async", trigger_async_mock)

    # Act
    WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow)

    # Assert
    trigger_async_mock.assert_called_once()


def test_trigger_workflow_execution_should_mark_tenant_rate_limited_when_quota_exceeded(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    webhook_trigger = _workflow_trigger(
        app_id="app-1",
        node_id="node-1",
        tenant_id="tenant-1",
        webhook_id="webhook-1",
    )
    workflow = _workflow(id="wf-1")

    session = MagicMock()
    _patch_session(monkeypatch, session)

    monkeypatch.setattr(
        service_module.EndUserService,
        "get_or_create_end_user_by_type",
        MagicMock(return_value=SimpleNamespace(id="end-user-1")),
    )
    monkeypatch.setattr(
        service_module.QuotaService,
        "reserve",
        MagicMock(side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1)),
    )
    mark_rate_limited_mock = MagicMock()
    monkeypatch.setattr(service_module.AppTriggerService, "mark_tenant_triggers_rate_limited", mark_rate_limited_mock)

    # Act / Assert
    with pytest.raises(QuotaExceededError):
        WebhookService.trigger_workflow_execution(webhook_trigger, {"body": {}}, workflow)
    mark_rate_limited_mock.assert_called_once_with("tenant-1")


def test_trigger_workflow_execution_should_log_and_reraise_unexpected_errors(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    webhook_trigger = _workflow_trigger(
        app_id="app-1",
        node_id="node-1",
        tenant_id="tenant-1",
        webhook_id="webhook-1",
    )
    workflow = _workflow(id="wf-1")

    session = MagicMock()
    _patch_session(monkeypatch, session)

    monkeypatch.setattr(
        service_module.EndUserService, "get_or_create_end_user_by_type", MagicMock(side_effect=RuntimeError("boom"))
    )
    logger_exception_mock = MagicMock()
    monkeypatch.setattr(service_module.logger, "exception", logger_exception_mock)

    # Act / Assert
    with pytest.raises(RuntimeError, match="boom"):
        WebhookService.trigger_workflow_execution(webhook_trigger, {"body": {}}, workflow)
    logger_exception_mock.assert_called_once()


def test_sync_webhook_relationships_should_raise_when_workflow_exceeds_node_limit() -> None:
    # Arrange
    app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1")
    workflow = _workflow(
        walk_nodes=lambda _node_type: [
            (f"node-{i}", {}) for i in range(WebhookService.MAX_WEBHOOK_NODES_PER_WORKFLOW + 1)
        ]
    )

    # Act / Assert
    with pytest.raises(ValueError, match="maximum webhook node limit"):
        WebhookService.sync_webhook_relationships(app, workflow)


def test_sync_webhook_relationships_should_raise_when_lock_not_acquired(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1")
    workflow = _workflow(walk_nodes=lambda _node_type: [("node-1", {})])

    lock = MagicMock()
    lock.acquire.return_value = False
    monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None))
    monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock))

    # Act / Assert
    with pytest.raises(RuntimeError, match="Failed to acquire lock"):
        WebhookService.sync_webhook_relationships(app, workflow)


def test_sync_webhook_relationships_should_create_missing_records_and_delete_stale_records(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Arrange
    app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1")
    workflow = _workflow(walk_nodes=lambda _node_type: [("node-new", {})])

    class _WorkflowWebhookTrigger:
        app_id = "app_id"
        tenant_id = "tenant_id"
        webhook_id = "webhook_id"
        node_id = "node_id"

        def __init__(self, app_id: str, tenant_id: str, node_id: str, webhook_id: str, created_by: str) -> None:
            self.id = None
            self.app_id = app_id
            self.tenant_id = tenant_id
            self.node_id = node_id
            self.webhook_id = webhook_id
            self.created_by = created_by

    class _Select:
        def where(self, *args: Any, **kwargs: Any) -> "_Select":
            return self

    class _Session:
        def __init__(self) -> None:
            self.added: list[Any] = []
            self.deleted: list[Any] = []
            self.commit_count = 0
            self.existing_records = [SimpleNamespace(node_id="node-stale")]

        def scalars(self, _stmt: Any) -> Any:
            return SimpleNamespace(all=lambda: self.existing_records)

        def add(self, obj: Any) -> None:
            self.added.append(obj)

        def flush(self) -> None:
            for idx, obj in enumerate(self.added, start=1):
                if obj.id is None:
                    obj.id = f"rec-{idx}"

        def commit(self) -> None:
            self.commit_count += 1

        def delete(self, obj: Any) -> None:
            self.deleted.append(obj)

    lock = MagicMock()
    lock.acquire.return_value = True
    lock.release.return_value = None

    fake_session = _Session()

    monkeypatch.setattr(service_module, "WorkflowWebhookTrigger", _WorkflowWebhookTrigger)
    monkeypatch.setattr(service_module, "select", MagicMock(return_value=_Select()))
    monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None))
    monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock))
    redis_set_mock = MagicMock()
    redis_delete_mock = MagicMock()
    monkeypatch.setattr(service_module.redis_client, "set", redis_set_mock)
    monkeypatch.setattr(service_module.redis_client, "delete", redis_delete_mock)
    monkeypatch.setattr(WebhookService, "generate_webhook_id", MagicMock(return_value="generated-webhook-id"))
    _patch_session(monkeypatch, fake_session)

    # Act
    WebhookService.sync_webhook_relationships(app, workflow)

    # Assert
    assert len(fake_session.added) == 1
    assert len(fake_session.deleted) == 1
    redis_set_mock.assert_called_once()
    redis_delete_mock.assert_called_once()
    lock.release.assert_called_once()


def test_sync_webhook_relationships_should_log_when_lock_release_fails(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    app = _app(id="app-1", tenant_id="tenant-1", created_by="user-1")
    workflow = _workflow(walk_nodes=lambda _node_type: [])

    class _Select:
        def where(self, *args: Any, **kwargs: Any) -> "_Select":
            return self

    class _Session:
        def scalars(self, _stmt: Any) -> Any:
            return SimpleNamespace(all=lambda: [])

        def commit(self) -> None:
            return None

    lock = MagicMock()
    lock.acquire.return_value = True
    lock.release.side_effect = RuntimeError("release failed")

    logger_exception_mock = MagicMock()

    monkeypatch.setattr(service_module, "select", MagicMock(return_value=_Select()))
    monkeypatch.setattr(service_module.redis_client, "get", MagicMock(return_value=None))
    monkeypatch.setattr(service_module.redis_client, "lock", MagicMock(return_value=lock))
    monkeypatch.setattr(service_module.logger, "exception", logger_exception_mock)
    _patch_session(monkeypatch, _Session())

    # Act
    WebhookService.sync_webhook_relationships(app, workflow)

    # Assert
    assert logger_exception_mock.call_count == 1


def test_generate_webhook_response_should_fallback_when_response_body_is_not_json() -> None:
    # Arrange
    node_config = {"data": {"status_code": 200, "response_body": "{bad-json"}}

    # Act
    body, status = WebhookService.generate_webhook_response(node_config)

    # Assert
    assert status == 200
    assert "message" in body


def test_generate_webhook_id_should_return_24_character_identifier() -> None:
    # Arrange

    # Act
    webhook_id = WebhookService.generate_webhook_id()

    # Assert
    assert isinstance(webhook_id, str)
    assert len(webhook_id) == 24


def test_sanitize_key_should_return_original_value_for_non_string_input() -> None:
    # Arrange

    # Act
    result = WebhookService._sanitize_key(123)  # type: ignore[arg-type]

    # Assert
    assert result == 123

