"""Unit tests for recommended app orchestration and SQLite-backed trial state."""

from __future__ import annotations

import uuid
from typing import TypedDict, Unpack, cast
from unittest.mock import MagicMock, patch

import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session

from enums.deployment_edition import DeploymentEdition
from models.model import AccountTrialAppRecord, App, AppMode, TrialApp
from services import recommended_app_service as service_module
from services.recommended_app_service import RecommendedAppService

pytestmark = pytest.mark.parametrize(
    "sqlite_session",
    [(TrialApp, AccountTrialAppRecord, App)],
    indirect=True,
)


class RecommendedAppPayload(TypedDict, total=False):
    id: str
    app_id: str
    name: str
    description: str
    category: str
    icon: str
    model_config: object
    workflows: list[str]
    tools: list[str]
    can_trial: bool


class AppsResponse(TypedDict):
    recommended_apps: list[RecommendedAppPayload] | None
    categories: list[str]


class AppDetailKwargs(TypedDict, total=False):
    category: str
    icon: str
    model_config: object
    workflows: list[str]
    tools: list[str]


@pytest.mark.parametrize(
    ("edition", "enterprise_enabled", "feature_enabled", "expected"),
    [
        ("CLOUD", False, True, True),
        ("CLOUD", False, False, False),
        ("SELF_HOSTED", False, True, False),
        ("SELF_HOSTED", True, True, False),
    ],
)
def test_trial_app_policy_is_cloud_only(
    monkeypatch: pytest.MonkeyPatch,
    sqlite_session: Session,
    edition: str,
    enterprise_enabled: bool,
    feature_enabled: bool,
    expected: bool,
) -> None:
    monkeypatch.setattr(service_module.dify_config, "EDITION", edition)
    monkeypatch.setattr(service_module.dify_config, "ENTERPRISE_ENABLED", enterprise_enabled)
    monkeypatch.setattr(service_module.dify_config, "ENABLE_TRIAL_APP", feature_enabled)

    assert RecommendedAppService.is_trial_app_enabled() is expected


# ── Helpers ────────────────────────────────────────────────────────────


def _apps_response(
    recommended_apps: list[RecommendedAppPayload] | None = None,
    categories: list[str] | None = None,
) -> AppsResponse:
    if recommended_apps is None:
        recommended_apps = [
            {"app_id": "app-1", "name": "Test App 1", "description": "d1", "category": "productivity"},
            {"app_id": "app-2", "name": "Test App 2", "description": "d2", "category": "communication"},
        ]
    if categories is None:
        categories = ["productivity", "communication", "utilities"]
    return {"recommended_apps": recommended_apps, "categories": categories}


def _app_detail(
    app_id: str = "app-123",
    name: str = "Test App",
    description: str = "Test description",
    **kwargs: Unpack[AppDetailKwargs],
) -> RecommendedAppPayload:
    detail = RecommendedAppPayload(
        id=app_id,
        name=name,
        description=description,
        category=kwargs.get("category", "productivity"),
        icon=kwargs.get("icon", "🚀"),
        model_config=kwargs.get("model_config", {}),
    )
    detail.update(**kwargs)
    return detail


def _mock_factory_for_apps(
    monkeypatch: pytest.MonkeyPatch,
    *,
    mode: str,
    result: AppsResponse,
    fallback_result: AppsResponse | None = None,
) -> tuple[MagicMock, MagicMock]:
    retrieval_instance = MagicMock()
    retrieval_instance.get_recommended_apps_and_categories.return_value = result
    retrieval_factory = MagicMock(return_value=retrieval_instance)
    monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", mode, raising=False)
    monkeypatch.setattr(
        service_module.RecommendAppRetrievalFactory,
        "get_recommend_app_factory",
        MagicMock(return_value=retrieval_factory),
    )
    builtin_instance = MagicMock()
    if fallback_result is not None:
        builtin_instance.fetch_recommended_apps_from_builtin.return_value = fallback_result
    monkeypatch.setattr(
        service_module.RecommendAppRetrievalFactory,
        "get_buildin_recommend_app_retrieval",
        MagicMock(return_value=builtin_instance),
    )
    return retrieval_instance, builtin_instance


def _mock_factory_for_app_detail(
    monkeypatch: pytest.MonkeyPatch,
    *,
    result: RecommendedAppPayload | None,
) -> MagicMock:
    retrieval_instance = MagicMock()
    retrieval_instance.get_recommend_app_detail.return_value = result
    retrieval_factory = MagicMock(return_value=retrieval_instance)
    monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote", raising=False)
    monkeypatch.setattr(
        service_module.RecommendAppRetrievalFactory,
        "get_recommend_app_factory",
        MagicMock(return_value=retrieval_factory),
    )
    return retrieval_instance


def _persist_app(session: Session, *, name: str) -> App:
    app = App(
        tenant_id=str(uuid.uuid4()),
        name=name,
        mode=AppMode.CHAT,
        enable_site=True,
        enable_api=True,
    )
    app.id = str(uuid.uuid4())
    session.add(app)
    session.commit()
    return app


# ── Pure logic tests: get_recommended_apps_and_categories ──────────────


class TestRecommendedAppServiceGetApps:
    @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
    @patch("services.recommended_app_service.dify_config")
    def test_success_with_apps(
        self, mock_config: MagicMock, mock_factory_class: MagicMock, sqlite_session: Session
    ) -> None:
        mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
        expected = _apps_response()

        mock_instance = MagicMock()
        mock_instance.get_recommended_apps_and_categories.return_value = expected
        mock_factory = MagicMock(return_value=mock_instance)
        mock_factory_class.get_recommend_app_factory.return_value = mock_factory

        result = RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session)

        assert result == expected
        assert len(result["recommended_apps"]) == 2
        assert len(result["categories"]) == 3
        mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote")
        mock_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US", session=sqlite_session)

    @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
    @patch("services.recommended_app_service.dify_config")
    def test_fallback_to_builtin_when_empty(
        self, mock_config: MagicMock, mock_factory_class: MagicMock, sqlite_session: Session
    ) -> None:
        mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
        empty_response = AppsResponse(recommended_apps=[], categories=[])
        builtin_response = _apps_response(
            recommended_apps=[{"app_id": "builtin-1", "name": "Builtin App", "category": "default"}]
        )

        mock_remote_instance = MagicMock()
        mock_remote_instance.get_recommended_apps_and_categories.return_value = empty_response
        mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_remote_instance)

        mock_builtin_instance = MagicMock()
        mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response
        mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance

        result = RecommendedAppService.get_recommended_apps_and_categories("zh-CN", session=sqlite_session)

        assert result == builtin_response
        assert result["recommended_apps"][0]["app_id"] == "builtin-1"
        mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US")

    @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
    @patch("services.recommended_app_service.dify_config")
    def test_fallback_when_none_recommended_apps(
        self, mock_config: MagicMock, mock_factory_class: MagicMock, sqlite_session: Session
    ) -> None:
        mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "db"
        none_response = AppsResponse(recommended_apps=None, categories=["test"])
        builtin_response = _apps_response()

        mock_db_instance = MagicMock()
        mock_db_instance.get_recommended_apps_and_categories.return_value = none_response
        mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_db_instance)

        mock_builtin_instance = MagicMock()
        mock_builtin_instance.fetch_recommended_apps_from_builtin.return_value = builtin_response
        mock_factory_class.get_buildin_recommend_app_retrieval.return_value = mock_builtin_instance

        result = RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session)

        assert result == builtin_response
        mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once()

    @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
    @patch("services.recommended_app_service.dify_config")
    def test_different_languages(
        self, mock_config: MagicMock, mock_factory_class: MagicMock, sqlite_session: Session
    ) -> None:
        mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "builtin"

        for language in ["en-US", "zh-CN", "ja-JP", "fr-FR"]:
            lang_response = _apps_response(
                recommended_apps=[{"app_id": f"app-{language}", "name": f"App {language}", "category": "test"}]
            )
            mock_instance = MagicMock()
            mock_instance.get_recommended_apps_and_categories.return_value = lang_response
            mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance)

            result = RecommendedAppService.get_recommended_apps_and_categories(language, session=sqlite_session)

            assert result["recommended_apps"][0]["app_id"] == f"app-{language}"
            mock_instance.get_recommended_apps_and_categories.assert_called_with(language, session=sqlite_session)

    @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
    @patch("services.recommended_app_service.dify_config")
    def test_uses_correct_factory_mode(
        self, mock_config: MagicMock, mock_factory_class: MagicMock, sqlite_session: Session
    ) -> None:
        for mode in ["remote", "builtin", "db"]:
            mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode
            response = _apps_response()
            mock_instance = MagicMock()
            mock_instance.get_recommended_apps_and_categories.return_value = response
            mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance)

            RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session)

            mock_factory_class.get_recommend_app_factory.assert_called_with(mode)


# ── Database-backed tests: get_app ─────────────────────────────────────


class TestRecommendedAppServiceGetApp:
    def test_returns_normal_recommended_app(self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
        app = _persist_app(sqlite_session, name="Recommended App")

        retrieval_instance = _mock_factory_for_app_detail(
            monkeypatch,
            result=RecommendedAppPayload(id=app.id),
        )
        trial_policy = MagicMock(side_effect=AssertionError("get_app must not inspect trial policy"))
        monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", trial_policy)

        result = RecommendedAppService.get_app(app.id, session=sqlite_session)

        assert result is app
        retrieval_instance.get_recommend_app_detail.assert_called_once_with(app.id, session=sqlite_session)
        trial_policy.assert_not_called()

    def test_returns_none_when_app_is_not_recommended(
        self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
    ) -> None:
        app = _persist_app(sqlite_session, name="Private App")

        retrieval_instance = _mock_factory_for_app_detail(monkeypatch, result=None)

        result = RecommendedAppService.get_app(app.id, session=sqlite_session)

        assert result is None
        retrieval_instance.get_recommend_app_detail.assert_called_once_with(app.id, session=sqlite_session)


# ── Pure logic tests: get_recommend_app_detail ─────────────────────────


class TestRecommendedAppServiceGetDetail:
    @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
    @patch("services.recommended_app_service.dify_config")
    def test_returns_retrieval_detail_when_trial_disabled(
        self,
        mock_config: MagicMock,
        mock_factory_class: MagicMock,
        sqlite_session: Session,
    ) -> None:
        mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
        mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
        mock_config.ENABLE_TRIAL_APP = True
        cases: list[tuple[str, RecommendedAppPayload]] = [
            (
                "complex-app",
                _app_detail(
                    app_id="complex-app",
                    name="Complex App",
                    model_config={
                        "provider": "openai",
                        "model": "gpt-4",
                        "parameters": {"temperature": 0.7, "max_tokens": 2000, "top_p": 1.0},
                    },
                    workflows=["workflow-1", "workflow-2"],
                    tools=["tool-1", "tool-2", "tool-3"],
                ),
            ),
            ("app-empty", RecommendedAppPayload()),
        ]

        for app_id, expected in cases:
            mock_instance = MagicMock()
            mock_instance.get_recommend_app_detail.return_value = expected
            mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance)

            result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session)

            assert result is not None
            assert result["can_trial"] is False
            mock_instance.get_recommend_app_detail.assert_called_once_with(app_id, session=sqlite_session)

    @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
    @patch("services.recommended_app_service.dify_config")
    def test_different_modes(
        self,
        mock_config: MagicMock,
        mock_factory_class: MagicMock,
        sqlite_session: Session,
    ) -> None:
        mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
        mock_config.ENABLE_TRIAL_APP = True
        for mode in ["remote", "builtin", "db"]:
            mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode
            detail = _app_detail(app_id="test-app", name=f"App from {mode}")
            mock_instance = MagicMock()
            mock_instance.get_recommend_app_detail.return_value = detail
            mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance)

            result = RecommendedAppService.get_recommend_app_detail("test-app", session=sqlite_session)

            assert result is not None
            mock_instance.get_recommend_app_detail.assert_called_with("test-app", session=sqlite_session)
            mock_factory_class.get_recommend_app_factory.assert_called_with(mode)


# ── Pure logic tests: get_learn_dify_apps ──────────────────────────────


class TestRecommendedAppServiceGetLearnDifyApps:
    @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
    @patch("services.recommended_app_service.dify_config")
    def test_uses_configured_retrieval_source(
        self,
        mock_config: MagicMock,
        mock_factory_class: MagicMock,
        sqlite_session: Session,
    ) -> None:
        mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
        mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
        mock_config.ENABLE_TRIAL_APP = True
        expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow")
        mock_instance = MagicMock()
        mock_instance.get_learn_dify_apps.return_value = {
            "recommended_apps": [expected_app],
            "categories": ["Workflow"],
        }
        mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance)

        result = RecommendedAppService.get_learn_dify_apps("en-US", session=sqlite_session)

        assert result == {"recommended_apps": [{**expected_app, "can_trial": False}]}
        mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote")
        mock_instance.get_learn_dify_apps.assert_called_once_with("en-US", session=sqlite_session)

    @patch("services.recommended_app_service.dify_config")
    def test_sets_can_trial_when_trial_feature_enabled(
        self, mock_config: MagicMock, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
    ) -> None:
        mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "db"
        app = RecommendedAppPayload(app_id="app-1", category="Workflow")
        mock_retrieval_instance = MagicMock()
        mock_retrieval_instance.get_learn_dify_apps.return_value = {
            "recommended_apps": [app],
            "categories": ["Workflow"],
        }
        mock_retrieval_factory = MagicMock(return_value=mock_retrieval_instance)
        monkeypatch.setattr(
            service_module.RecommendAppRetrievalFactory,
            "get_recommend_app_factory",
            MagicMock(return_value=mock_retrieval_factory),
        )
        monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=True))
        trial_app_ids = MagicMock(return_value={"app-1"})
        monkeypatch.setattr(RecommendedAppService, "_get_trial_app_ids", trial_app_ids)

        result = RecommendedAppService.get_learn_dify_apps("en-US", session=sqlite_session)

        assert result["recommended_apps"][0]["can_trial"] is True
        trial_app_ids.assert_called_once_with(sqlite_session, ["app-1"])


# ── Integration tests: trial app features (real DB) ────────────────────


class TestRecommendedAppServiceTrialFeatures:
    def test_get_apps_should_not_query_trial_table_when_disabled(
        self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
    ) -> None:
        upstream_result = AppsResponse(
            recommended_apps=[RecommendedAppPayload(app_id="app-1", can_trial=True)], categories=["all"]
        )
        retrieval_instance, builtin_instance = _mock_factory_for_apps(
            monkeypatch, mode="remote", result=upstream_result
        )
        monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=False))
        trial_app_ids = MagicMock(side_effect=AssertionError("disabled trial must not query TrialApp"))
        monkeypatch.setattr(RecommendedAppService, "_get_trial_app_ids", trial_app_ids)

        result = RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session)

        assert result["recommended_apps"][0]["can_trial"] is False
        trial_app_ids.assert_not_called()
        retrieval_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US", session=sqlite_session)
        builtin_instance.fetch_recommended_apps_from_builtin.assert_not_called()

    def test_get_apps_should_enrich_can_trial_when_enabled(
        self, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        app_id_1 = str(uuid.uuid4())
        app_id_2 = str(uuid.uuid4())
        tenant_id = str(uuid.uuid4())

        # app_id_1 has a TrialApp record; app_id_2 does not
        sqlite_session.add(TrialApp(app_id=app_id_1, tenant_id=tenant_id))
        sqlite_session.commit()

        remote_result = AppsResponse(recommended_apps=[], categories=[])
        fallback_result = AppsResponse(
            recommended_apps=[RecommendedAppPayload(app_id=app_id_1), RecommendedAppPayload(app_id=app_id_2)],
            categories=["all"],
        )
        _, builtin_instance = _mock_factory_for_apps(
            monkeypatch, mode="remote", result=remote_result, fallback_result=fallback_result
        )
        monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=True))

        result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP", session=sqlite_session)

        builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US")
        assert result["recommended_apps"][0]["can_trial"] is True
        assert result["recommended_apps"][1]["can_trial"] is False

    @pytest.mark.parametrize("has_trial_app", [True, False])
    def test_get_detail_should_set_can_trial_when_enabled(
        self,
        sqlite_session: Session,
        monkeypatch: pytest.MonkeyPatch,
        has_trial_app: bool,
    ) -> None:
        app_id = str(uuid.uuid4())
        tenant_id = str(uuid.uuid4())

        if has_trial_app:
            sqlite_session.add(TrialApp(app_id=app_id, tenant_id=tenant_id))
            sqlite_session.commit()

        detail = RecommendedAppPayload(id=app_id, name="Test App")
        retrieval_instance = MagicMock()
        retrieval_instance.get_recommend_app_detail.return_value = detail
        retrieval_factory = MagicMock(return_value=retrieval_instance)
        monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote", raising=False)
        monkeypatch.setattr(
            service_module.RecommendAppRetrievalFactory,
            "get_recommend_app_factory",
            MagicMock(return_value=retrieval_factory),
        )
        monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=True))

        result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session)
        assert result is not None
        detail_result = cast(RecommendedAppPayload, result)

        assert detail_result["id"] == app_id
        assert detail_result["can_trial"] is has_trial_app

    @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
    @patch("services.recommended_app_service.dify_config")
    def test_get_detail_returns_none_before_reading_trial_flag(
        self,
        mock_config: MagicMock,
        mock_factory_class: MagicMock,
        sqlite_session: Session,
    ) -> None:
        mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
        mock_instance = MagicMock()
        mock_instance.get_recommend_app_detail.return_value = None
        mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance)
        trial_policy = MagicMock(side_effect=AssertionError("missing app must not inspect trial policy"))
        with patch.object(RecommendedAppService, "is_trial_app_enabled", trial_policy):
            result = RecommendedAppService.get_recommend_app_detail("nonexistent", session=sqlite_session)

        assert result is None
        mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent", session=sqlite_session)
        trial_policy.assert_not_called()

    def test_add_trial_app_record_increments_count_for_existing(self, sqlite_session: Session) -> None:
        app_id = str(uuid.uuid4())
        account_id = str(uuid.uuid4())

        sqlite_session.add(AccountTrialAppRecord(app_id=app_id, account_id=account_id, count=3))
        sqlite_session.commit()

        RecommendedAppService.add_trial_app_record(app_id, account_id, session=sqlite_session)

        sqlite_session.expire_all()
        record = sqlite_session.scalar(
            select(AccountTrialAppRecord)
            .where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id)
            .limit(1)
        )
        assert record is not None
        assert record.count == 4

    def test_add_trial_app_record_creates_new_record(self, sqlite_session: Session) -> None:
        app_id = str(uuid.uuid4())
        account_id = str(uuid.uuid4())

        RecommendedAppService.add_trial_app_record(app_id, account_id, session=sqlite_session)

        sqlite_session.expire_all()
        record = sqlite_session.scalar(
            select(AccountTrialAppRecord)
            .where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id)
            .limit(1)
        )
        assert record is not None
        assert record.app_id == app_id
        assert record.account_id == account_id
        assert record.count == 1
