#!/usr/bin/env python3
# pyright: reportAttributeAccessIssue=false
"""
Google Ads API 클라이언트 단위 테스트 (Mock 기반)

테스트 대상: GoogleAdsClient (utils.google_ads_client)

테스트 항목:
- 초기화: 환경변수 정상/누락 시 동작
- 캠페인 CRUD: list, get, create, update, delete
- 광고그룹 CRUD: list, create, update, delete
- 키워드 관리: list, add, update_status
- 인사이트: get_insights (campaign/ad_group/keyword), 잘못된 entity_type ValueError
- RSA 광고 생성: create_responsive_search_ad (유효성 검증 포함)
- 인터페이스 일관성: MetaAdsClient와 공통 메서드명 확인

모든 외부 호출(Google Ads SDK)은 Mock으로 대체하여
실제 Google Ads API를 호출하지 않는다.
"""

import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent))


# ---------------------------------------------------------------------------
# 모듈 레벨 패치: google-ads SDK 관련 패키지가 없는 환경 대응
# ---------------------------------------------------------------------------
# google.ads.googleads 패키지와 google.protobuf 패키지를 sys.modules에 미리 주입하여
# import 시점에 ModuleNotFoundError가 발생하지 않도록 한다.


def _ensure_fake_module(dotted_name: str) -> types.ModuleType:
    """dotted_name 경로의 가짜 모듈을 sys.modules에 등록(없을 때만)하고 반환한다."""
    parts = dotted_name.split(".")
    for i in range(1, len(parts) + 1):
        key = ".".join(parts[:i])
        if key not in sys.modules:
            mod = types.ModuleType(key)
            # 상위 모듈에 하위 속성으로도 붙여준다
            if i > 1:
                parent_key = ".".join(parts[: i - 1])
                setattr(sys.modules[parent_key], parts[i - 1], mod)
            sys.modules[key] = mod
    return sys.modules[dotted_name]


for _pkg in (
    "google",
    "google.ads",
    "google.ads.googleads",
    "google.ads.googleads.client",
    "google.ads.googleads.errors",
    "google.protobuf",
    "google.protobuf.json_format",
    "google.protobuf.field_mask_pb2",
):
    _ensure_fake_module(_pkg)

# SDK 클래스들을 MagicMock으로 교체
_MockSDKClientCls = MagicMock()
sys.modules["google.ads.googleads.client"].GoogleAdsClient = _MockSDKClientCls

_MockGoogleAdsException = type("GoogleAdsException", (Exception,), {})
sys.modules["google.ads.googleads.errors"].GoogleAdsException = _MockGoogleAdsException

sys.modules["google.protobuf.json_format"].MessageToDict = MagicMock(return_value={})
sys.modules["google.protobuf.field_mask_pb2"].FieldMask = MagicMock()

# ---------------------------------------------------------------------------
# GoogleAdsClient 임포트 (SDK mock이 준비된 후에 수행)
# ---------------------------------------------------------------------------

with (
    patch("utils.google_ads_client.load_env_keys"),
    patch("utils.google_ads_client._GoogleAdsClient") as _patched_sdk_cls,
):
    _patched_sdk_cls.load_from_dict.return_value = MagicMock()
    from utils.google_ads_client import GoogleAdsClient  # type: ignore[import-not-found]


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def mock_env(monkeypatch):
    """5개의 필수 Google Ads 환경변수를 세팅하는 fixture."""
    monkeypatch.setenv("GOOGLE_ADS_DEVELOPER_TOKEN", "test-dev-token")
    monkeypatch.setenv("GOOGLE_ADS_CLIENT_ID", "test-client-id")
    monkeypatch.setenv("GOOGLE_ADS_CLIENT_SECRET", "test-client-secret")
    monkeypatch.setenv("GOOGLE_ADS_REFRESH_TOKEN", "test-refresh-token")
    monkeypatch.setenv("GOOGLE_ADS_CUSTOMER_ID", "1234567890")


@pytest.fixture
def mock_sdk_client():
    """Google Ads SDK 클라이언트 인스턴스 mock을 반환하는 fixture."""
    with patch("utils.google_ads_client._GoogleAdsClient") as mock_cls:
        mock_instance = MagicMock()
        mock_cls.load_from_dict.return_value = mock_instance
        yield mock_instance


@pytest.fixture
def client(mock_env, mock_sdk_client):
    """테스트용 GoogleAdsClient 인스턴스를 제공하는 fixture."""
    with patch("utils.google_ads_client.load_env_keys"):
        return GoogleAdsClient()


def _make_search_row(**kwargs) -> MagicMock:
    """GoogleAdsService.search() 한 행(row) mock을 생성하는 헬퍼."""
    row = MagicMock()
    for attr_path, value in kwargs.items():
        # "campaign.id" → row.campaign.id = value
        parts = attr_path.split(".")
        obj = row
        for part in parts[:-1]:
            obj = getattr(obj, part)
        setattr(obj, parts[-1], value)
    return row


def _make_mutate_result(*resource_names: str) -> MagicMock:
    """mutate_xxx() 응답 mock을 생성하는 헬퍼."""
    result_mock = MagicMock()
    result_mock.results = [MagicMock() for _ in resource_names]
    for mock_r, rn in zip(result_mock.results, resource_names):
        mock_r.resource_name = rn
    return result_mock


# ---------------------------------------------------------------------------
# 1. 초기화 테스트
# ---------------------------------------------------------------------------


class TestGoogleAdsClientInit:
    """GoogleAdsClient.__init__ 초기화 동작 검증"""

    def test_init_success(self, mock_env, mock_sdk_client):
        """필수 환경변수가 모두 설정된 경우 예외 없이 초기화된다."""
        with patch("utils.google_ads_client.load_env_keys"):
            c = GoogleAdsClient()

        assert c is not None
        # _client 속성이 SDK 인스턴스(mock)로 설정되었는지 확인
        assert c._client is mock_sdk_client

    def test_init_missing_env(self, monkeypatch):
        """모든 필수 환경변수가 누락된 경우 ValueError가 발생한다."""
        for key in (
            "GOOGLE_ADS_DEVELOPER_TOKEN",
            "GOOGLE_ADS_CLIENT_ID",
            "GOOGLE_ADS_CLIENT_SECRET",
            "GOOGLE_ADS_REFRESH_TOKEN",
            "GOOGLE_ADS_CUSTOMER_ID",
        ):
            monkeypatch.delenv(key, raising=False)

        with patch("utils.google_ads_client.load_env_keys"), patch("utils.google_ads_client._GoogleAdsClient"):
            with pytest.raises(ValueError):
                GoogleAdsClient()

    def test_init_missing_single_env_raises_value_error(self, mock_env, monkeypatch):
        """단일 필수 환경변수(GOOGLE_ADS_DEVELOPER_TOKEN)가 누락되면 ValueError가 발생한다."""
        monkeypatch.delenv("GOOGLE_ADS_DEVELOPER_TOKEN", raising=False)

        with patch("utils.google_ads_client.load_env_keys"), patch("utils.google_ads_client._GoogleAdsClient"):
            with pytest.raises(ValueError, match="GOOGLE_ADS_DEVELOPER_TOKEN"):
                GoogleAdsClient()


# ---------------------------------------------------------------------------
# 2. 계정 정보 테스트
# ---------------------------------------------------------------------------


class TestGetAccountInfo:
    """get_account_info: 계정 정보 dict 반환 검증"""

    def test_get_account_info_returns_dict(self, client, mock_sdk_client):
        """get_account_info()는 dict를 반환한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service

        row = MagicMock()
        row.customer.id = 1234567890
        row.customer.descriptive_name = "테스트 계정"
        row.customer.currency_code = "KRW"
        row.customer.time_zone = "Asia/Seoul"
        row.customer.auto_tagging_enabled = True
        row.customer.status.name = "ENABLED"
        mock_ga_service.search.return_value = [row]

        result = client.get_account_info()

        assert isinstance(result, dict)
        mock_ga_service.search.assert_called_once()

    def test_get_account_info_empty_response_returns_empty_dict(self, client, mock_sdk_client):
        """API 응답이 비어 있으면 빈 dict를 반환한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        result = client.get_account_info()

        assert result == {}


# ---------------------------------------------------------------------------
# 3. 캠페인 CRUD 테스트
# ---------------------------------------------------------------------------


class TestListCampaigns:
    """list_campaigns: list[dict] 반환 검증"""

    def test_list_campaigns(self, client, mock_sdk_client):
        """list_campaigns() 호출 시 list[dict]를 반환한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service

        row1 = MagicMock()
        row1.campaign.id = 111
        row1.campaign.name = "캠페인 알파"
        row1.campaign.status.name = "ENABLED"
        row1.campaign.advertising_channel_type.name = "SEARCH"
        row1.campaign.start_date = "2024-01-01"
        row1.campaign.end_date = ""
        row1.campaign_budget.amount_micros = 1000000

        row2 = MagicMock()
        row2.campaign.id = 222
        row2.campaign.name = "캠페인 베타"
        row2.campaign.status.name = "PAUSED"
        row2.campaign.advertising_channel_type.name = "SEARCH"
        row2.campaign.start_date = "2024-02-01"
        row2.campaign.end_date = ""
        row2.campaign_budget.amount_micros = 2000000

        mock_ga_service.search.return_value = [row1, row2]

        result = client.list_campaigns()

        assert isinstance(result, list)
        assert len(result) == 2
        assert all(isinstance(item, dict) for item in result)

    def test_list_campaigns_default_limit_in_query(self, client, mock_sdk_client):
        """기본 limit(25)이 GAQL 쿼리에 포함된다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        client.list_campaigns()

        _, kwargs = mock_ga_service.search.call_args
        assert "25" in kwargs.get("query", "")

    def test_list_campaigns_custom_limit_in_query(self, client, mock_sdk_client):
        """사용자 지정 limit(10)이 GAQL 쿼리에 포함된다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        client.list_campaigns(limit=10)

        _, kwargs = mock_ga_service.search.call_args
        assert "10" in kwargs.get("query", "")

    def test_list_campaigns_empty_returns_empty_list(self, client, mock_sdk_client):
        """캠페인이 없으면 빈 리스트를 반환한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        result = client.list_campaigns()

        assert result == []


class TestGetCampaign:
    """get_campaign: dict 반환 검증"""

    def test_get_campaign(self, client, mock_sdk_client):
        """get_campaign() 호출 시 dict를 반환한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service

        row = MagicMock()
        row.campaign.id = 111
        row.campaign.name = "대상 캠페인"
        row.campaign.status.name = "ENABLED"
        row.campaign.advertising_channel_type.name = "SEARCH"
        row.campaign.start_date = "2024-01-01"
        row.campaign.end_date = ""
        row.campaign_budget.amount_micros = 3000000
        mock_ga_service.search.return_value = [row]

        result = client.get_campaign("111")

        assert isinstance(result, dict)

    def test_get_campaign_not_found_raises_value_error(self, client, mock_sdk_client):
        """존재하지 않는 campaign_id 조회 시 ValueError가 발생한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        with pytest.raises(ValueError, match="캠페인을 찾을 수 없습니다"):
            client.get_campaign("999999")

    def test_get_campaign_includes_id_in_query(self, client, mock_sdk_client):
        """get_campaign()이 campaign_id를 WHERE 절에 포함하여 조회한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service

        row = MagicMock()
        row.campaign.id = 555
        row.campaign.name = "ID 포함 쿼리 확인"
        row.campaign.status.name = "PAUSED"
        row.campaign.advertising_channel_type.name = "SEARCH"
        row.campaign.start_date = "2024-01-01"
        row.campaign.end_date = ""
        row.campaign_budget.amount_micros = 500000
        mock_ga_service.search.return_value = [row]

        client.get_campaign("555")

        _, kwargs = mock_ga_service.search.call_args
        assert "555" in kwargs.get("query", "")


class TestCreateCampaign:
    """create_campaign: dict 반환 검증"""

    def _setup_create_mocks(self, mock_sdk_client):
        """create_campaign에 필요한 CampaignBudgetService/CampaignService mock을 설정한다."""
        mock_budget_service = MagicMock()
        mock_campaign_service = MagicMock()

        mock_budget_result = _make_mutate_result("customers/123/campaignBudgets/456")
        mock_budget_service.mutate_campaign_budgets.return_value = mock_budget_result

        mock_campaign_result = _make_mutate_result("customers/123/campaigns/789")
        mock_campaign_service.mutate_campaigns.return_value = mock_campaign_result

        def get_service_side_effect(service_name):
            if service_name == "CampaignBudgetService":
                return mock_budget_service
            return mock_campaign_service

        mock_sdk_client.get_service.side_effect = get_service_side_effect
        mock_sdk_client.get_type.return_value = MagicMock()
        return mock_budget_service, mock_campaign_service

    def test_create_campaign(self, client, mock_sdk_client):
        """create_campaign() 호출 시 dict를 반환한다."""
        self._setup_create_mocks(mock_sdk_client)

        result = client.create_campaign(
            name="새 캠페인",
            budget_amount=1000000,
            status="PAUSED",
        )

        assert isinstance(result, dict)

    def test_create_campaign_result_contains_expected_keys(self, client, mock_sdk_client):
        """create_campaign() 반환 dict에 id, resource_name, name, status가 포함된다."""
        self._setup_create_mocks(mock_sdk_client)

        result = client.create_campaign(name="키 확인 캠페인", budget_amount=2000000)

        assert "id" in result
        assert "resource_name" in result
        assert "name" in result
        assert "status" in result

    def test_create_campaign_default_status_paused(self, client, mock_sdk_client):
        """status 인자를 생략하면 기본값 PAUSED가 반환 dict에 포함된다."""
        self._setup_create_mocks(mock_sdk_client)

        result = client.create_campaign(name="기본상태 캠페인", budget_amount=500000)

        assert result.get("status") == "PAUSED"

    def test_create_campaign_calls_budget_and_campaign_services(self, client, mock_sdk_client):
        """create_campaign()이 예산 생성 후 캠페인 생성을 순서대로 호출한다."""
        mock_budget_svc, mock_campaign_svc = self._setup_create_mocks(mock_sdk_client)

        client.create_campaign(name="순서 확인 캠페인", budget_amount=1000000)

        mock_budget_svc.mutate_campaign_budgets.assert_called_once()
        mock_campaign_svc.mutate_campaigns.assert_called_once()


class TestUpdateCampaign:
    """update_campaign: dict 반환 검증"""

    def test_update_campaign(self, client, mock_sdk_client):
        """update_campaign() 호출 시 dict를 반환한다."""
        mock_campaign_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_campaign_service
        mock_sdk_client.get_type.return_value = MagicMock()

        mock_response = _make_mutate_result("customers/123/campaigns/111")
        mock_campaign_service.mutate_campaigns.return_value = mock_response

        result = client.update_campaign("111", name="업데이트된 캠페인", status="ENABLED")

        assert isinstance(result, dict)

    def test_update_campaign_result_contains_id_and_updated_fields(self, client, mock_sdk_client):
        """update_campaign() 반환 dict에 id와 updated_fields가 포함된다."""
        mock_svc = MagicMock()
        mock_sdk_client.get_service.return_value = mock_svc
        mock_sdk_client.get_type.return_value = MagicMock()
        mock_svc.mutate_campaigns.return_value = _make_mutate_result("customers/123/campaigns/222")

        result = client.update_campaign("222", name="필드 확인", status="PAUSED")

        assert "id" in result
        assert "updated_fields" in result

    def test_delete_campaign(self, client, mock_sdk_client):
        """delete_campaign() 호출 시 True를 반환한다."""
        mock_svc = MagicMock()
        mock_sdk_client.get_service.return_value = mock_svc
        mock_sdk_client.get_type.return_value = MagicMock()
        mock_svc.mutate_campaigns.return_value = _make_mutate_result("customers/123/campaigns/333")

        result = client.delete_campaign("333")

        assert result is True

    def test_delete_campaign_calls_mutate_campaigns(self, client, mock_sdk_client):
        """delete_campaign()이 CampaignService.mutate_campaigns를 호출한다."""
        mock_svc = MagicMock()
        mock_sdk_client.get_service.return_value = mock_svc
        mock_sdk_client.get_type.return_value = MagicMock()
        mock_svc.mutate_campaigns.return_value = _make_mutate_result("customers/123/campaigns/444")

        client.delete_campaign("444")

        mock_svc.mutate_campaigns.assert_called_once()


# ---------------------------------------------------------------------------
# 4. 광고그룹 CRUD 테스트
# ---------------------------------------------------------------------------


class TestListAdGroups:
    """list_ad_groups: list[dict] 반환 검증"""

    def test_list_ad_groups(self, client, mock_sdk_client):
        """list_ad_groups() 호출 시 list[dict]를 반환한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service

        row = MagicMock()
        row.ad_group.id = 10
        row.ad_group.name = "광고그룹 A"
        row.ad_group.status.name = "ENABLED"
        row.ad_group.type_.name = "SEARCH_STANDARD"
        row.ad_group.cpc_bid_micros = 1000000
        row.campaign.id = 111
        row.campaign.name = "캠페인 알파"
        mock_ga_service.search.return_value = [row]

        result = client.list_ad_groups()

        assert isinstance(result, list)
        assert all(isinstance(item, dict) for item in result)

    def test_list_ad_groups_with_campaign_id_in_query(self, client, mock_sdk_client):
        """campaign_id가 지정되면 GAQL 쿼리에 WHERE campaign.id 조건이 포함된다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        client.list_ad_groups(campaign_id="999")

        _, kwargs = mock_ga_service.search.call_args
        assert "999" in kwargs.get("query", "")

    def test_list_ad_groups_without_campaign_id_no_where_clause(self, client, mock_sdk_client):
        """campaign_id가 None이면 WHERE 절 없이 전체 광고그룹을 조회한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        client.list_ad_groups(campaign_id=None)

        _, kwargs = mock_ga_service.search.call_args
        # WHERE campaign.id 조건이 없어야 한다
        assert "WHERE campaign.id" not in kwargs.get("query", "")


class TestCreateAdGroup:
    """create_ad_group: dict 반환 검증"""

    def test_create_ad_group(self, client, mock_sdk_client):
        """create_ad_group() 호출 시 dict를 반환한다."""
        mock_campaign_svc = MagicMock()
        mock_ad_group_svc = MagicMock()

        def get_service_side_effect(name):
            if name == "CampaignService":
                return mock_campaign_svc
            if name == "AdGroupService":
                return mock_ad_group_svc
            return MagicMock()

        mock_sdk_client.get_service.side_effect = get_service_side_effect
        mock_sdk_client.get_type.return_value = MagicMock()

        mock_ad_group_svc.mutate_ad_groups.return_value = _make_mutate_result("customers/123/adGroups/456")

        result = client.create_ad_group(
            campaign_id="111",
            name="새 광고그룹",
            cpc_bid=1000000,
            status="PAUSED",
        )

        assert isinstance(result, dict)

    def test_create_ad_group_default_status_paused(self, client, mock_sdk_client):
        """status 인자를 생략하면 기본값 PAUSED가 반환 dict에 포함된다."""
        mock_campaign_svc = MagicMock()
        mock_ad_group_svc = MagicMock()

        def get_service_side_effect(name):
            if name == "CampaignService":
                return mock_campaign_svc
            if name == "AdGroupService":
                return mock_ad_group_svc
            return MagicMock()

        mock_sdk_client.get_service.side_effect = get_service_side_effect
        mock_sdk_client.get_type.return_value = MagicMock()

        mock_ad_group_svc.mutate_ad_groups.return_value = _make_mutate_result("customers/123/adGroups/789")

        result = client.create_ad_group(
            campaign_id="111",
            name="기본상태 광고그룹",
            cpc_bid=500000,
        )

        assert result.get("status") == "PAUSED"

    def test_delete_ad_group(self, client, mock_sdk_client):
        """delete_ad_group() 호출 시 True를 반환한다."""
        mock_ad_group_svc = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ad_group_svc
        mock_sdk_client.get_type.return_value = MagicMock()
        mock_ad_group_svc.mutate_ad_groups.return_value = _make_mutate_result("customers/123/adGroups/456")

        result = client.delete_ad_group("456")

        assert result is True

    def test_update_ad_group(self, client, mock_sdk_client):
        """update_ad_group() 호출 시 dict를 반환한다."""
        mock_ad_group_svc = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ad_group_svc
        mock_sdk_client.get_type.return_value = MagicMock()
        mock_ad_group_svc.mutate_ad_groups.return_value = _make_mutate_result("customers/123/adGroups/456")

        result = client.update_ad_group("456", name="업데이트된 광고그룹")

        assert isinstance(result, dict)


# ---------------------------------------------------------------------------
# 5. 키워드 관리 테스트
# ---------------------------------------------------------------------------


class TestListKeywords:
    """list_keywords: list[dict] 반환 검증"""

    def test_list_keywords(self, client, mock_sdk_client):
        """list_keywords() 호출 시 list[dict]를 반환한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service

        row = MagicMock()
        row.ad_group_criterion.criterion_id = 1001
        row.ad_group_criterion.keyword.text = "파이썬 개발"
        row.ad_group_criterion.keyword.match_type.name = "BROAD"
        row.ad_group_criterion.status.name = "ENABLED"
        row.ad_group_criterion.cpc_bid_micros = 800000
        row.ad_group.id = 456
        mock_ga_service.search.return_value = [row]

        result = client.list_keywords(ad_group_id="456")

        assert isinstance(result, list)
        assert all(isinstance(item, dict) for item in result)

    def test_list_keywords_includes_ad_group_id_in_query(self, client, mock_sdk_client):
        """list_keywords()가 ad_group_id를 WHERE 절에 포함한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        client.list_keywords(ad_group_id="456")

        _, kwargs = mock_ga_service.search.call_args
        assert "456" in kwargs.get("query", "")

    def test_list_keywords_limit_in_query(self, client, mock_sdk_client):
        """list_keywords()에 limit 값이 GAQL 쿼리에 반영된다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        client.list_keywords(ad_group_id="456", limit=5)

        _, kwargs = mock_ga_service.search.call_args
        assert "5" in kwargs.get("query", "")


class TestAddKeywords:
    """add_keywords: list[dict] 반환 검증"""

    def test_add_keywords(self, client, mock_sdk_client):
        """add_keywords() 호출 시 list[dict]를 반환한다."""
        mock_ad_group_svc = MagicMock()
        mock_criterion_svc = MagicMock()

        def get_service_side_effect(name):
            if name == "AdGroupService":
                return mock_ad_group_svc
            if name == "AdGroupCriterionService":
                return mock_criterion_svc
            return MagicMock()

        mock_sdk_client.get_service.side_effect = get_service_side_effect
        mock_sdk_client.get_type.return_value = MagicMock()

        mock_criterion_svc.mutate_ad_group_criteria.return_value = _make_mutate_result(
            "customers/123/adGroupCriteria/456~1001",
            "customers/123/adGroupCriteria/456~1002",
        )

        keywords = [
            {"text": "파이썬 개발", "match_type": "BROAD"},
            {"text": "구글 광고", "match_type": "EXACT"},
        ]
        result = client.add_keywords(ad_group_id="456", keywords=keywords)

        assert isinstance(result, list)
        assert all(isinstance(item, dict) for item in result)

    def test_add_keywords_calls_mutate_criteria_once(self, client, mock_sdk_client):
        """add_keywords()는 여러 키워드를 한 번의 mutate 호출로 추가한다."""
        mock_ad_group_svc = MagicMock()
        mock_criterion_svc = MagicMock()

        def get_service_side_effect(name):
            if name == "AdGroupService":
                return mock_ad_group_svc
            if name == "AdGroupCriterionService":
                return mock_criterion_svc
            return MagicMock()

        mock_sdk_client.get_service.side_effect = get_service_side_effect
        mock_sdk_client.get_type.return_value = MagicMock()

        mock_criterion_svc.mutate_ad_group_criteria.return_value = _make_mutate_result(
            "customers/123/adGroupCriteria/456~1001",
            "customers/123/adGroupCriteria/456~1002",
            "customers/123/adGroupCriteria/456~1003",
        )

        keywords = [
            {"text": "키워드1", "match_type": "BROAD"},
            {"text": "키워드2", "match_type": "PHRASE"},
            {"text": "키워드3", "match_type": "EXACT"},
        ]
        client.add_keywords(ad_group_id="456", keywords=keywords)

        mock_criterion_svc.mutate_ad_group_criteria.assert_called_once()

    def test_add_keywords_result_contains_resource_name(self, client, mock_sdk_client):
        """add_keywords() 반환 각 항목에 resource_name 키가 있다."""
        mock_ad_group_svc = MagicMock()
        mock_criterion_svc = MagicMock()

        def get_service_side_effect(name):
            if name == "AdGroupService":
                return mock_ad_group_svc
            if name == "AdGroupCriterionService":
                return mock_criterion_svc
            return MagicMock()

        mock_sdk_client.get_service.side_effect = get_service_side_effect
        mock_sdk_client.get_type.return_value = MagicMock()

        mock_criterion_svc.mutate_ad_group_criteria.return_value = _make_mutate_result(
            "customers/123/adGroupCriteria/456~9999",
        )

        result = client.add_keywords(
            ad_group_id="456",
            keywords=[{"text": "단일 키워드", "match_type": "BROAD"}],
        )

        assert len(result) == 1
        assert "resource_name" in result[0]


class TestUpdateKeywordStatus:
    """update_keyword_status: dict 반환 검증"""

    def test_update_keyword_status(self, client, mock_sdk_client):
        """update_keyword_status() 호출 시 dict를 반환한다."""
        mock_criterion_svc = MagicMock()
        mock_sdk_client.get_service.return_value = mock_criterion_svc
        mock_sdk_client.get_type.return_value = MagicMock()

        mock_criterion_svc.mutate_ad_group_criteria.return_value = _make_mutate_result(
            "customers/123/adGroupCriteria/456~1001"
        )

        result = client.update_keyword_status(
            keyword_criterion_id="1001",
            ad_group_id="456",
            status="PAUSED",
        )

        assert isinstance(result, dict)

    def test_update_keyword_status_contains_expected_keys(self, client, mock_sdk_client):
        """update_keyword_status() 반환 dict에 criterion_id, ad_group_id, status가 있다."""
        mock_criterion_svc = MagicMock()
        mock_sdk_client.get_service.return_value = mock_criterion_svc
        mock_sdk_client.get_type.return_value = MagicMock()

        mock_criterion_svc.mutate_ad_group_criteria.return_value = _make_mutate_result(
            "customers/123/adGroupCriteria/456~1001"
        )

        result = client.update_keyword_status(
            keyword_criterion_id="1001",
            ad_group_id="456",
            status="ENABLED",
        )

        assert result.get("criterion_id") == "1001"
        assert result.get("ad_group_id") == "456"
        assert result.get("status") == "ENABLED"


# ---------------------------------------------------------------------------
# 6. 인사이트 테스트
# ---------------------------------------------------------------------------


class TestGetInsights:
    """get_insights: list[dict] 반환 및 잘못된 entity_type 예외 검증"""

    def test_get_insights(self, client, mock_sdk_client):
        """get_insights() 호출 시 list[dict]를 반환한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service

        row = MagicMock()
        row.campaign.id = 111
        row.campaign.name = "분석 캠페인"
        row.segments.date = "2024-01-15"
        row.metrics.impressions = 5000
        row.metrics.clicks = 250
        row.metrics.cost_micros = 1500000
        row.metrics.ctr = 0.05
        row.metrics.average_cpc = 6000
        row.metrics.conversions = 10
        mock_ga_service.search.return_value = [row]

        result = client.get_insights("111", entity_type="campaign")

        assert isinstance(result, list)
        assert all(isinstance(item, dict) for item in result)

    def test_get_insights_default_entity_type_campaign(self, client, mock_sdk_client):
        """entity_type 기본값은 'campaign'이며, campaign 테이블을 FROM에 사용한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        client.get_insights("111")

        _, kwargs = mock_ga_service.search.call_args
        assert "campaign" in kwargs.get("query", "").lower()

    def test_get_insights_ad_group_type(self, client, mock_sdk_client):
        """entity_type='ad_group'으로 광고그룹 인사이트를 조회한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service

        row = MagicMock()
        row.ad_group.id = 456
        row.ad_group.name = "광고그룹 인사이트"
        row.segments.date = "2024-01-15"
        row.metrics.impressions = 1000
        row.metrics.clicks = 50
        row.metrics.cost_micros = 300000
        row.metrics.ctr = 0.05
        row.metrics.average_cpc = 6000
        row.metrics.conversions = 3
        mock_ga_service.search.return_value = [row]

        result = client.get_insights("456", entity_type="ad_group")

        assert isinstance(result, list)

    def test_get_insights_keyword_type(self, client, mock_sdk_client):
        """entity_type='keyword'로 키워드 인사이트를 조회한다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        result = client.get_insights("1001", entity_type="keyword")

        assert isinstance(result, list)
        _, kwargs = mock_ga_service.search.call_args
        assert "keyword_view" in kwargs.get("query", "")

    def test_get_insights_invalid_entity_type(self, client, mock_sdk_client):
        """지원하지 않는 entity_type 전달 시 ValueError가 발생한다."""
        with pytest.raises(ValueError):
            client.get_insights("111", entity_type="invalid_type")

    def test_get_insights_invalid_entity_type_message(self, client, mock_sdk_client):
        """ValueError 메시지에 잘못된 entity_type 값이 포함된다."""
        with pytest.raises(ValueError, match="unsupported_entity"):
            client.get_insights("111", entity_type="unsupported_entity")

    def test_get_insights_default_date_range_last_7_days(self, client, mock_sdk_client):
        """date_range 기본값 'LAST_7_DAYS'가 GAQL 쿼리에 포함된다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        client.get_insights("111")

        _, kwargs = mock_ga_service.search.call_args
        assert "LAST_7_DAYS" in kwargs.get("query", "")

    def test_get_insights_custom_date_range(self, client, mock_sdk_client):
        """사용자 지정 date_range가 GAQL 쿼리에 포함된다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service
        mock_ga_service.search.return_value = []

        client.get_insights("111", entity_type="campaign", date_range="LAST_30_DAYS")

        _, kwargs = mock_ga_service.search.call_args
        assert "LAST_30_DAYS" in kwargs.get("query", "")

    def test_get_insights_returns_records_with_entity_info(self, client, mock_sdk_client):
        """get_insights() 반환 dict 각 항목에 entity_id와 entity_type이 포함된다."""
        mock_ga_service = MagicMock()
        mock_sdk_client.get_service.return_value = mock_ga_service

        row = MagicMock()
        row.campaign.id = 777
        row.campaign.name = "리포트 캠페인"
        row.segments.date = "2024-01-20"
        row.metrics.impressions = 2000
        row.metrics.clicks = 100
        row.metrics.cost_micros = 600000
        row.metrics.ctr = 0.05
        row.metrics.average_cpc = 6000
        row.metrics.conversions = 5
        mock_ga_service.search.return_value = [row]

        result = client.get_insights("777", entity_type="campaign")

        assert len(result) == 1
        assert result[0].get("entity_id") == "777"
        assert result[0].get("entity_type") == "campaign"


# ---------------------------------------------------------------------------
# 7. 반응형 검색 광고 생성 테스트
# ---------------------------------------------------------------------------


class TestCreateResponsiveSearchAd:
    """create_responsive_search_ad: dict 반환 및 유효성 검증"""

    def _setup_rsa_mocks(self, mock_sdk_client):
        """create_responsive_search_ad에 필요한 서비스 mock을 설정한다."""
        mock_ad_group_svc = MagicMock()
        mock_ad_group_ad_svc = MagicMock()

        def get_service_side_effect(name):
            if name == "AdGroupService":
                return mock_ad_group_svc
            if name == "AdGroupAdService":
                return mock_ad_group_ad_svc
            return MagicMock()

        mock_sdk_client.get_service.side_effect = get_service_side_effect

        # get_type mock (AdGroupAdOperation, AdTextAsset 모두 대응)
        mock_sdk_client.get_type.return_value = MagicMock()

        mock_ad_group_ad_svc.mutate_ad_group_ads.return_value = _make_mutate_result("customers/123/adGroupAds/456~999")
        return mock_ad_group_svc, mock_ad_group_ad_svc

    def test_create_responsive_search_ad(self, client, mock_sdk_client):
        """create_responsive_search_ad() 호출 시 dict를 반환한다."""
        self._setup_rsa_mocks(mock_sdk_client)

        result = client.create_responsive_search_ad(
            ad_group_id="456",
            headlines=["헤드라인1", "헤드라인2", "헤드라인3"],
            descriptions=["설명1", "설명2"],
            final_url="https://example.com",
        )

        assert isinstance(result, dict)

    def test_create_responsive_search_ad_result_contains_expected_keys(self, client, mock_sdk_client):
        """반환 dict에 resource_name, ad_group_id, final_url이 포함된다."""
        self._setup_rsa_mocks(mock_sdk_client)

        result = client.create_responsive_search_ad(
            ad_group_id="456",
            headlines=["H1", "H2", "H3"],
            descriptions=["D1", "D2"],
            final_url="https://example.com/landing",
        )

        assert "resource_name" in result
        assert "ad_group_id" in result
        assert "final_url" in result

    def test_create_responsive_search_ad_too_few_headlines_raises(self, client, mock_sdk_client):
        """헤드라인이 3개 미만이면 ValueError가 발생한다."""
        self._setup_rsa_mocks(mock_sdk_client)

        with pytest.raises(ValueError, match="헤드라인"):
            client.create_responsive_search_ad(
                ad_group_id="456",
                headlines=["H1", "H2"],  # 2개 (3개 미만)
                descriptions=["D1", "D2"],
                final_url="https://example.com",
            )

    def test_create_responsive_search_ad_too_many_headlines_raises(self, client, mock_sdk_client):
        """헤드라인이 15개를 초과하면 ValueError가 발생한다."""
        self._setup_rsa_mocks(mock_sdk_client)

        with pytest.raises(ValueError, match="헤드라인"):
            client.create_responsive_search_ad(
                ad_group_id="456",
                headlines=[f"H{i}" for i in range(16)],  # 16개 (15개 초과)
                descriptions=["D1", "D2"],
                final_url="https://example.com",
            )

    def test_create_responsive_search_ad_too_few_descriptions_raises(self, client, mock_sdk_client):
        """설명이 2개 미만이면 ValueError가 발생한다."""
        self._setup_rsa_mocks(mock_sdk_client)

        with pytest.raises(ValueError, match="설명"):
            client.create_responsive_search_ad(
                ad_group_id="456",
                headlines=["H1", "H2", "H3"],
                descriptions=["D1"],  # 1개 (2개 미만)
                final_url="https://example.com",
            )

    def test_create_responsive_search_ad_too_many_descriptions_raises(self, client, mock_sdk_client):
        """설명이 4개를 초과하면 ValueError가 발생한다."""
        self._setup_rsa_mocks(mock_sdk_client)

        with pytest.raises(ValueError, match="설명"):
            client.create_responsive_search_ad(
                ad_group_id="456",
                headlines=["H1", "H2", "H3"],
                descriptions=["D1", "D2", "D3", "D4", "D5"],  # 5개 (4개 초과)
                final_url="https://example.com",
            )

    def test_create_responsive_search_ad_max_headlines_and_descriptions(self, client, mock_sdk_client):
        """최대 허용 개수(헤드라인 15개, 설명 4개)로 정상 생성된다."""
        self._setup_rsa_mocks(mock_sdk_client)

        result = client.create_responsive_search_ad(
            ad_group_id="456",
            headlines=[f"헤드라인{i}" for i in range(1, 16)],
            descriptions=[f"설명{i}" for i in range(1, 5)],
            final_url="https://example.com/max",
        )

        assert isinstance(result, dict)


# ---------------------------------------------------------------------------
# 8. 인터페이스 일관성 테스트
# ---------------------------------------------------------------------------


class TestInterfaceConsistency:
    """GoogleAdsClient와 MetaAdsClient의 공통 인터페이스 검증"""

    def test_interface_consistency(self):
        """GoogleAdsClient가 MetaAdsClient와 공통 메서드를 모두 구현한다."""
        common_methods = [
            "list_campaigns",
            "get_campaign",
            "create_campaign",
            "update_campaign",
            "delete_campaign",
            "get_insights",
            "get_account_info",
        ]

        missing = [m for m in common_methods if not hasattr(GoogleAdsClient, m)]

        assert not missing, f"GoogleAdsClient에 다음 공통 메서드가 누락되었습니다: {missing}"

    def test_all_common_methods_are_callable(self):
        """GoogleAdsClient의 공통 메서드들이 callable이다."""
        common_methods = [
            "list_campaigns",
            "get_campaign",
            "create_campaign",
            "update_campaign",
            "delete_campaign",
            "get_insights",
            "get_account_info",
        ]

        not_callable = [m for m in common_methods if not callable(getattr(GoogleAdsClient, m, None))]

        assert not not_callable, f"GoogleAdsClient에서 callable이 아닌 메서드: {not_callable}"

    def test_google_ads_specific_methods_exist(self):
        """Google Ads 전용 메서드(키워드/광고그룹/RSA 관련)가 존재한다."""
        google_specific_methods = [
            "list_ad_groups",
            "create_ad_group",
            "update_ad_group",
            "delete_ad_group",
            "list_keywords",
            "add_keywords",
            "update_keyword_status",
            "create_responsive_search_ad",
        ]

        missing = [m for m in google_specific_methods if not hasattr(GoogleAdsClient, m)]

        assert not missing, f"GoogleAdsClient에 다음 전용 메서드가 누락되었습니다: {missing}"


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
