#!/usr/bin/env python3
"""
utils/meta_ads_client.py - MetaAdsClient 단위 테스트

테스트 항목:
- 초기화: 환경변수 정상/누락 시 동작
- 토큰 관리: exchange_token, check_token, update_env_token
- 캠페인 CRUD: list, create, get, update, delete
- 광고세트 CRUD: list, create, update, delete
- 크리에이티브: upload_image, create_creative
- 인사이트: get_insights (campaign/adset/ad), 잘못된 타입 ValueError

모든 외부 호출(requests.get, Facebook SDK 메서드)은 Mock으로 대체하여
실제 Meta API를 호출하지 않는다.
"""

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

import pytest
from requests.exceptions import HTTPError

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


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

# MetaAdsClient 임포트는 최초 1회만 수행한다.
# patch('utils.meta_ads_client.load_env_keys')와
# patch('utils.meta_ads_client.FacebookAdsApi.init')은 모듈 레벨에서 적용한다.
with patch("utils.meta_ads_client.load_env_keys"), patch("utils.meta_ads_client.FacebookAdsApi.init"):
    from utils.meta_ads_client import MetaAdsClient  # type: ignore[import-not-found]


def _make_client(monkeypatch) -> MetaAdsClient:
    """테스트용 환경변수를 주입하고, SDK API 연결 없이 MetaAdsClient를 생성한다.

    _account는 MagicMock으로 교체하여 AdAccount SDK 호출을 모두 차단한다.
    """
    monkeypatch.setenv("META_APP_ID", "test_app_id")
    monkeypatch.setenv("META_APP_SECRET", "test_app_secret")
    monkeypatch.setenv("META_ACCESS_TOKEN", "test_access_token")
    monkeypatch.setenv("META_AD_ACCOUNT_ID", "act_123456789")

    with patch("utils.meta_ads_client.FacebookAdsApi.init"), patch("utils.meta_ads_client.AdAccount"):
        c = MetaAdsClient()

    # _account를 MagicMock으로 교체하여 모든 SDK 메서드 호출을 통제한다.
    c._account = MagicMock()
    return c


@pytest.fixture
def client(monkeypatch) -> MetaAdsClient:
    """각 테스트에서 재사용할 MetaAdsClient 인스턴스를 제공한다."""
    return _make_client(monkeypatch)


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


class TestMetaAdsClientInit:
    """MetaAdsClient.__init__ 초기화 동작 검증"""

    def test_init_success_with_all_env_vars(self, monkeypatch):
        """필수 환경변수가 모두 설정된 경우 예외 없이 초기화되고 속성값이 정확히 설정된다."""
        monkeypatch.setenv("META_APP_ID", "test_app_id")
        monkeypatch.setenv("META_APP_SECRET", "test_app_secret")
        monkeypatch.setenv("META_ACCESS_TOKEN", "test_access_token")
        monkeypatch.setenv("META_AD_ACCOUNT_ID", "act_123456789")

        with patch("utils.meta_ads_client.FacebookAdsApi.init") as mock_init, patch("utils.meta_ads_client.AdAccount"):
            c = MetaAdsClient()

        assert c._app_id == "test_app_id"
        assert c._app_secret == "test_app_secret"
        assert c._access_token == "test_access_token"
        assert c._ad_account_id == "act_123456789"
        mock_init.assert_called_once_with("test_app_id", "test_app_secret", "test_access_token")

    def test_init_raises_value_error_when_access_token_missing(self, monkeypatch):
        """META_ACCESS_TOKEN이 없으면 ValueError가 발생하고 메시지에 키 이름이 포함된다."""
        monkeypatch.setenv("META_APP_ID", "test_app_id")
        monkeypatch.setenv("META_APP_SECRET", "test_app_secret")
        monkeypatch.delenv("META_ACCESS_TOKEN", raising=False)
        monkeypatch.setenv("META_AD_ACCOUNT_ID", "act_123456789")

        # load_env_keys가 .env.keys에서 토큰을 로드하지 못하도록 no-op으로 대체한다.
        with patch("utils.meta_ads_client.load_env_keys"), patch("utils.meta_ads_client.FacebookAdsApi.init"):
            with pytest.raises(ValueError, match="META_ACCESS_TOKEN"):
                MetaAdsClient()

    def test_init_raises_value_error_when_multiple_keys_missing(self, monkeypatch):
        """여러 필수 키가 모두 없으면 ValueError가 발생한다."""
        for key in ("META_APP_ID", "META_APP_SECRET", "META_ACCESS_TOKEN", "META_AD_ACCOUNT_ID"):
            monkeypatch.delenv(key, raising=False)

        with patch("utils.meta_ads_client.load_env_keys"), patch("utils.meta_ads_client.FacebookAdsApi.init"):
            with pytest.raises(ValueError):
                MetaAdsClient()


# ---------------------------------------------------------------------------
# 2. 내부 헬퍼 테스트
# ---------------------------------------------------------------------------


class TestToDict:
    """_to_dict 정적 메서드 동작 검증"""

    def test_to_dict_uses_export_all_data_when_available(self, client):
        """export_all_data()를 가진 객체는 해당 메서드로 변환된다."""
        obj = MagicMock()
        obj.export_all_data.return_value = {"id": "123", "name": "test"}

        result = client._to_dict(obj)

        assert result == {"id": "123", "name": "test"}
        obj.export_all_data.assert_called_once()

    def test_to_dict_falls_back_to_dict_conversion(self, client):
        """export_all_data()가 없는 객체는 dict()로 변환된다."""
        result = client._to_dict({"key": "value"})

        assert result == {"key": "value"}


# ---------------------------------------------------------------------------
# 3. 토큰 관리 테스트
# ---------------------------------------------------------------------------


class TestExchangeToken:
    """exchange_token: 단기 → 장기 토큰 교환 동작 검증"""

    def test_exchange_token_returns_new_token_on_success(self, client):
        """Graph API가 access_token을 응답하면 새 토큰을 반환하고 내부 상태를 갱신한다."""
        mock_resp = MagicMock()
        mock_resp.json.return_value = {
            "access_token": "new_long_lived_token",
            "token_type": "bearer",
        }

        with (
            patch("utils.meta_ads_client.requests.get", return_value=mock_resp) as mock_get,
            patch("utils.meta_ads_client.FacebookAdsApi.init"),
        ):
            result = client.exchange_token()

        assert result == "new_long_lived_token"
        assert client._access_token == "new_long_lived_token"
        mock_resp.raise_for_status.assert_called_once()
        called_url = mock_get.call_args[0][0]
        assert "oauth/access_token" in called_url

    def test_exchange_token_raises_on_http_error(self, client):
        """HTTP 오류 발생 시 raise_for_status가 예외를 전파한다."""
        mock_resp = MagicMock()
        mock_resp.raise_for_status.side_effect = HTTPError("401 Unauthorized")

        with patch("utils.meta_ads_client.requests.get", return_value=mock_resp):
            with pytest.raises(HTTPError):
                client.exchange_token()

    def test_exchange_token_raises_value_error_when_no_access_token_in_response(self, client):
        """응답 JSON에 access_token 키가 없으면 ValueError가 발생한다."""
        mock_resp = MagicMock()
        mock_resp.json.return_value = {"error": {"message": "Invalid token"}}

        with patch("utils.meta_ads_client.requests.get", return_value=mock_resp):
            with pytest.raises(ValueError, match="access_token"):
                client.exchange_token()


class TestCheckToken:
    """check_token: debug_token 엔드포인트 호출 동작 검증"""

    def test_check_token_returns_data_field(self, client):
        """debug_token 응답의 data 필드를 반환한다."""
        mock_resp = MagicMock()
        mock_resp.json.return_value = {
            "data": {
                "is_valid": True,
                "expires_at": 9999999999,
                "scopes": ["ads_management"],
            }
        }

        with patch("utils.meta_ads_client.requests.get", return_value=mock_resp) as mock_get:
            result = client.check_token()

        assert result["is_valid"] is True
        assert result["scopes"] == ["ads_management"]
        mock_resp.raise_for_status.assert_called_once()
        called_url = mock_get.call_args[0][0]
        assert "debug_token" in called_url

    def test_check_token_returns_raw_response_when_no_data_key(self, client):
        """응답에 data 키가 없으면 응답 전체를 반환한다."""
        mock_resp = MagicMock()
        mock_resp.json.return_value = {"is_valid": False}

        with patch("utils.meta_ads_client.requests.get", return_value=mock_resp):
            result = client.check_token()

        assert result == {"is_valid": False}


class TestUpdateEnvToken:
    """update_env_token: .env.keys 파일의 토큰 라인 업데이트 동작 검증"""

    def test_update_env_token_replaces_export_prefix_line(self, client, tmp_path):
        """'export META_ACCESS_TOKEN=...' 형식의 라인을 새 토큰으로 교체한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text(
            "export META_APP_ID=app_id\nexport META_ACCESS_TOKEN=old_token\nexport META_APP_SECRET=secret\n"
        )
        client._env_keys_path = str(env_file)

        client.update_env_token("brand_new_token")

        content = env_file.read_text()
        assert "brand_new_token" in content
        assert "old_token" not in content
        assert "META_APP_ID=app_id" in content
        assert client._access_token == "brand_new_token"

    def test_update_env_token_replaces_plain_prefix_line(self, client, tmp_path):
        """'META_ACCESS_TOKEN=...' (export 없음) 형식의 라인도 교체한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text("META_ACCESS_TOKEN=old_plain_token\n")
        client._env_keys_path = str(env_file)

        client.update_env_token("new_plain_token")

        content = env_file.read_text()
        assert "new_plain_token" in content
        assert "old_plain_token" not in content

    def test_update_env_token_appends_when_line_not_found(self, client, tmp_path):
        """파일에 META_ACCESS_TOKEN 라인이 없으면 파일 끝에 추가한다."""
        env_file = tmp_path / ".env.keys"
        env_file.write_text("export META_APP_ID=app_id\n")
        client._env_keys_path = str(env_file)

        client.update_env_token("appended_token")

        content = env_file.read_text()
        assert "appended_token" in content

    def test_update_env_token_does_nothing_when_file_not_exists(self, client, tmp_path):
        """파일이 존재하지 않으면 예외 없이 조기 반환한다."""
        client._env_keys_path = str(tmp_path / "nonexistent.env.keys")

        # 예외 없이 실행되어야 한다.
        client.update_env_token("some_token")


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


class TestGetAccountInfo:
    """get_account_info: AdAccount.api_get 호출 및 dict 변환 검증"""

    def test_get_account_info_returns_dict(self, client):
        """api_get 결과를 dict로 변환하여 반환한다."""
        mock_data = MagicMock()
        mock_data.export_all_data.return_value = {
            "id": "act_123456789",
            "name": "Test Account",
            "account_status": 1,
            "currency": "KRW",
        }
        client._account.api_get.return_value = mock_data

        result = client.get_account_info()

        assert result["name"] == "Test Account"
        assert result["currency"] == "KRW"
        client._account.api_get.assert_called_once()


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


class TestListCampaigns:
    """list_campaigns: AdAccount.get_campaigns mock을 통한 list[dict] 반환 검증"""

    def test_list_campaigns_returns_list_of_dicts(self, client):
        """get_campaigns 결과를 dict 목록으로 변환하여 반환한다."""
        camp1, camp2 = MagicMock(), MagicMock()
        camp1.export_all_data.return_value = {"id": "camp_1", "name": "Campaign A"}
        camp2.export_all_data.return_value = {"id": "camp_2", "name": "Campaign B"}
        client._account.get_campaigns.return_value = [camp1, camp2]

        result = client.list_campaigns()

        assert len(result) == 2
        assert result[0]["name"] == "Campaign A"
        assert result[1]["id"] == "camp_2"

    def test_list_campaigns_passes_limit_param(self, client):
        """limit 인자가 params에 포함되어 SDK에 전달된다."""
        client._account.get_campaigns.return_value = []

        client.list_campaigns(limit=10)

        _, kwargs = client._account.get_campaigns.call_args
        assert kwargs["params"]["limit"] == 10

    def test_list_campaigns_uses_custom_fields(self, client):
        """fields 인자를 지정하면 해당 필드 목록이 SDK에 전달된다."""
        client._account.get_campaigns.return_value = []
        custom_fields = ["id", "name"]

        client.list_campaigns(fields=custom_fields)

        _, kwargs = client._account.get_campaigns.call_args
        assert kwargs["fields"] == custom_fields


class TestCreateCampaign:
    """create_campaign: AdAccount.create_campaign mock을 통한 생성 동작 검증"""

    def test_create_campaign_returns_dict(self, client):
        """create_campaign 결과를 dict로 변환하여 반환한다."""
        mock_campaign = MagicMock()
        mock_campaign.export_all_data.return_value = {
            "id": "camp_new",
            "name": "New Campaign",
            "status": "PAUSED",
        }
        client._account.create_campaign.return_value = mock_campaign

        result = client.create_campaign(
            name="New Campaign",
            objective="OUTCOME_TRAFFIC",
            status="PAUSED",
        )

        assert result["id"] == "camp_new"
        assert result["status"] == "PAUSED"

    def test_create_campaign_includes_daily_budget_when_given(self, client):
        """daily_budget이 지정되면 params에 포함된다."""
        mock_campaign = MagicMock()
        mock_campaign.export_all_data.return_value = {"id": "camp_budget"}
        client._account.create_campaign.return_value = mock_campaign

        client.create_campaign(
            name="Budget Campaign",
            objective="OUTCOME_AWARENESS",
            daily_budget=10000,
        )

        _, kwargs = client._account.create_campaign.call_args
        params = kwargs["params"]
        assert params.get("daily_budget") == 10000

    def test_create_campaign_omits_daily_budget_when_none(self, client):
        """daily_budget=None이면 params에 daily_budget 키가 없다."""
        mock_campaign = MagicMock()
        mock_campaign.export_all_data.return_value = {"id": "camp_no_budget"}
        client._account.create_campaign.return_value = mock_campaign

        client.create_campaign(name="No Budget", objective="OUTCOME_TRAFFIC")

        _, kwargs = client._account.create_campaign.call_args
        params = kwargs["params"]
        assert "daily_budget" not in params

    def test_create_campaign_default_special_ad_categories_empty(self, client):
        """special_ad_categories를 지정하지 않으면 빈 리스트가 전달된다."""
        mock_campaign = MagicMock()
        mock_campaign.export_all_data.return_value = {"id": "camp_cat"}
        client._account.create_campaign.return_value = mock_campaign

        client.create_campaign(name="Cat Campaign", objective="OUTCOME_TRAFFIC")

        _, kwargs = client._account.create_campaign.call_args
        assert kwargs["params"]["special_ad_categories"] == []


class TestGetCampaign:
    """get_campaign: Campaign.api_get mock을 통한 단일 조회 검증"""

    def test_get_campaign_returns_dict(self, client):
        """Campaign(id).api_get() 결과를 dict로 반환한다."""
        mock_result = MagicMock()
        mock_result.export_all_data.return_value = {
            "id": "camp_123",
            "name": "Target Campaign",
            "status": "ACTIVE",
        }

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.api_get.return_value = mock_result
            result = client.get_campaign("camp_123")

        assert result["id"] == "camp_123"
        assert result["status"] == "ACTIVE"
        MockCampaign.assert_called_once_with("camp_123")


class TestUpdateCampaign:
    """update_campaign: Campaign.api_update mock을 통한 업데이트 검증"""

    def test_update_campaign_returns_dict(self, client):
        """api_update 결과를 dict로 반환한다."""
        mock_result = MagicMock()
        mock_result.export_all_data.return_value = {"success": True}

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.api_update.return_value = mock_result
            result = client.update_campaign("camp_123", status="ACTIVE", name="Updated")

        assert result["success"] is True
        _, kwargs = MockCampaign.return_value.api_update.call_args
        assert kwargs["params"]["status"] == "ACTIVE"

    def test_update_campaign_passes_kwargs_as_params(self, client):
        """**params 키워드 인자가 api_update의 params 딕셔너리로 전달된다."""
        mock_result = MagicMock()
        mock_result.export_all_data.return_value = {}

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.api_update.return_value = mock_result
            client.update_campaign("camp_456", daily_budget=5000)

        _, kwargs = MockCampaign.return_value.api_update.call_args
        assert kwargs["params"]["daily_budget"] == 5000


class TestDeleteCampaign:
    """delete_campaign: Campaign.api_delete mock을 통한 삭제 검증"""

    def test_delete_campaign_returns_true_on_success(self, client):
        """api_delete 성공 시 True를 반환한다."""
        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.api_delete.return_value = None
            result = client.delete_campaign("camp_del")

        assert result is True
        MockCampaign.assert_called_once_with("camp_del")
        MockCampaign.return_value.api_delete.assert_called_once()

    def test_delete_campaign_propagates_sdk_exception(self, client):
        """SDK가 FacebookRequestError를 발생시키면 그대로 전파된다."""
        from facebook_business.exceptions import FacebookRequestError

        exc = FacebookRequestError(
            message="API error",
            request_context={"method": "DELETE", "path": "/camp_error"},
            http_status=400,
            http_headers={},
            body={"error": {"message": "Campaign not found", "code": 100}},
        )

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.api_delete.side_effect = exc

            with pytest.raises(FacebookRequestError):
                client.delete_campaign("camp_error")


# ---------------------------------------------------------------------------
# 6. 광고세트 CRUD 테스트
# ---------------------------------------------------------------------------


class TestListAdsets:
    """list_adsets: 계정 전체 및 캠페인별 광고세트 목록 조회 검증"""

    def test_list_adsets_from_account_when_no_campaign_id(self, client):
        """campaign_id가 없으면 계정 전체 광고세트를 조회한다."""
        adset = MagicMock()
        adset.export_all_data.return_value = {"id": "adset_1", "name": "AdSet A"}
        client._account.get_ad_sets.return_value = [adset]

        result = client.list_adsets()

        assert len(result) == 1
        assert result[0]["name"] == "AdSet A"
        client._account.get_ad_sets.assert_called_once()

    def test_list_adsets_from_campaign_when_campaign_id_given(self, client):
        """campaign_id가 지정되면 해당 캠페인의 광고세트를 조회한다."""
        adset = MagicMock()
        adset.export_all_data.return_value = {"id": "adset_camp", "campaign_id": "camp_xyz"}

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.get_ad_sets.return_value = [adset]
            result = client.list_adsets(campaign_id="camp_xyz")

        assert result[0]["campaign_id"] == "camp_xyz"
        MockCampaign.assert_called_once_with("camp_xyz")
        MockCampaign.return_value.get_ad_sets.assert_called_once()

    def test_list_adsets_passes_limit(self, client):
        """limit 인자가 SDK 호출 params에 포함된다."""
        client._account.get_ad_sets.return_value = []

        client.list_adsets(limit=5)

        _, kwargs = client._account.get_ad_sets.call_args
        assert kwargs["params"]["limit"] == 5


class TestCreateAdset:
    """create_adset: AdAccount.create_ad_set mock을 통한 생성 검증"""

    def test_create_adset_returns_dict(self, client):
        """create_ad_set 결과를 dict로 변환하여 반환한다."""
        mock_adset = MagicMock()
        mock_adset.export_all_data.return_value = {
            "id": "adset_new",
            "name": "New AdSet",
            "status": "PAUSED",
        }
        client._account.create_ad_set.return_value = mock_adset

        targeting = {"geo_locations": {"countries": ["KR"]}}
        result = client.create_adset(
            campaign_id="camp_abc",
            name="New AdSet",
            daily_budget=5000,
            targeting=targeting,
            optimization_goal="REACH",
            billing_event="IMPRESSIONS",
        )

        assert result["id"] == "adset_new"
        assert result["status"] == "PAUSED"

    def test_create_adset_passes_correct_params(self, client):
        """모든 파라미터가 SDK create_ad_set 호출에 올바르게 전달된다."""
        mock_adset = MagicMock()
        mock_adset.export_all_data.return_value = {"id": "adset_p"}
        client._account.create_ad_set.return_value = mock_adset

        targeting = {"age_min": 18, "age_max": 65}
        client.create_adset(
            campaign_id="camp_p",
            name="Param AdSet",
            daily_budget=2000,
            targeting=targeting,
            optimization_goal="LINK_CLICKS",
            billing_event="LINK_CLICKS",
            status="ACTIVE",
        )

        _, kwargs = client._account.create_ad_set.call_args
        params = kwargs["params"]
        assert params["campaign_id"] == "camp_p"
        assert params["daily_budget"] == 2000
        assert params["targeting"] == targeting
        assert params["status"] == "ACTIVE"


class TestUpdateAdset:
    """update_adset: AdSet.api_update mock을 통한 업데이트 검증"""

    def test_update_adset_returns_dict(self, client):
        """api_update 결과를 dict로 반환한다."""
        mock_result = MagicMock()
        mock_result.export_all_data.return_value = {"updated": True}

        with patch("utils.meta_ads_client.AdSet") as MockAdSet:
            MockAdSet.return_value.api_update.return_value = mock_result
            result = client.update_adset("adset_123", status="ACTIVE")

        assert result["updated"] is True
        _, kwargs = MockAdSet.return_value.api_update.call_args
        assert kwargs["params"]["status"] == "ACTIVE"


class TestDeleteAdset:
    """delete_adset: AdSet.api_delete mock을 통한 삭제 검증"""

    def test_delete_adset_returns_true(self, client):
        """api_delete 성공 시 True를 반환한다."""
        with patch("utils.meta_ads_client.AdSet") as MockAdSet:
            MockAdSet.return_value.api_delete.return_value = None
            result = client.delete_adset("adset_del")

        assert result is True
        MockAdSet.assert_called_once_with("adset_del")


# ---------------------------------------------------------------------------
# 7. 크리에이티브 테스트
# ---------------------------------------------------------------------------


class TestUploadImage:
    """upload_image: AdImage 생성 및 hash 반환 검증"""

    def test_upload_image_returns_hash_from_flat_response(self, client, tmp_path):
        """hash 키가 최상위에 있는 응답에서 hash 문자열을 반환한다."""
        img_file = tmp_path / "test.png"
        img_file.write_bytes(b"PNG_CONTENT")

        mock_image = MagicMock()
        mock_image.export_all_data.return_value = {"hash": "abc123hash"}
        client._account.create_ad_image.return_value = mock_image

        result = client.upload_image(str(img_file))

        assert result == "abc123hash"
        client._account.create_ad_image.assert_called_once()

    def test_upload_image_returns_hash_from_nested_images_response(self, client, tmp_path):
        """images 키 아래에 중첩된 응답 구조에서도 hash를 추출한다."""
        img_file = tmp_path / "nested.jpg"
        img_file.write_bytes(b"JPG_CONTENT")

        mock_image = MagicMock()
        mock_image.export_all_data.return_value = {"images": {"nested.jpg": {"hash": "nested_hash_xyz"}}}
        client._account.create_ad_image.return_value = mock_image

        result = client.upload_image(str(img_file))

        assert result == "nested_hash_xyz"

    def test_upload_image_raises_file_not_found(self, client, tmp_path):
        """존재하지 않는 파일 경로를 전달하면 FileNotFoundError가 발생한다."""
        nonexistent = str(tmp_path / "no_image.png")

        with pytest.raises(FileNotFoundError, match="no_image.png"):
            client.upload_image(nonexistent)


class TestCreateCreative:
    """create_creative: AdAccount.create_ad_creative mock을 통한 생성 검증"""

    def test_create_creative_returns_dict(self, client):
        """create_ad_creative 결과를 dict로 변환하여 반환한다."""
        mock_creative = MagicMock()
        mock_creative.export_all_data.return_value = {
            "id": "creative_1",
            "name": "Test Creative",
        }
        client._account.create_ad_creative.return_value = mock_creative

        result = client.create_creative(
            name="Test Creative",
            image_hash="abc123",
            page_id="page_456",
            message="Buy now!",
            link="https://example.com",
        )

        assert result["id"] == "creative_1"

    def test_create_creative_includes_link_when_given(self, client):
        """link가 지정되면 link_data에 포함된다."""
        mock_creative = MagicMock()
        mock_creative.export_all_data.return_value = {"id": "c_link"}
        client._account.create_ad_creative.return_value = mock_creative

        client.create_creative(
            name="Link Creative",
            image_hash="hash_link",
            page_id="page_link",
            message="Click me",
            link="https://landing.example.com",
        )

        _, kwargs = client._account.create_ad_creative.call_args
        params = kwargs["params"]
        link_data = params["object_story_spec"]["link_data"]
        assert link_data["link"] == "https://landing.example.com"

    def test_create_creative_omits_link_when_none(self, client):
        """link=None이면 link_data에 link 키가 없다."""
        mock_creative = MagicMock()
        mock_creative.export_all_data.return_value = {"id": "c_no_link"}
        client._account.create_ad_creative.return_value = mock_creative

        client.create_creative(
            name="No Link",
            image_hash="hash_nolink",
            page_id="page_nolink",
            message="Image only",
        )

        _, kwargs = client._account.create_ad_creative.call_args
        params = kwargs["params"]
        link_data = params["object_story_spec"]["link_data"]
        assert "link" not in link_data

    def test_create_creative_sets_page_id_and_message(self, client):
        """object_story_spec에 page_id와 message가 올바르게 설정된다."""
        mock_creative = MagicMock()
        mock_creative.export_all_data.return_value = {"id": "c_page"}
        client._account.create_ad_creative.return_value = mock_creative

        client.create_creative(
            name="Page Creative",
            image_hash="hash_page",
            page_id="my_page_789",
            message="Hello World",
        )

        _, kwargs = client._account.create_ad_creative.call_args
        spec = kwargs["params"]["object_story_spec"]
        assert spec["page_id"] == "my_page_789"
        assert spec["link_data"]["message"] == "Hello World"
        assert spec["link_data"]["image_hash"] == "hash_page"


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


class TestGetInsights:
    """get_insights: 광고 성과 인사이트 조회 검증"""

    def _make_insight(self, data: dict) -> MagicMock:
        """export_all_data를 가진 인사이트 mock 객체를 생성한다."""
        m = MagicMock()
        m.export_all_data.return_value = data
        return m

    def test_get_insights_for_campaign_type(self, client):
        """object_type='campaign'일 때 Campaign 객체의 get_insights를 호출한다."""
        insight = self._make_insight({"impressions": "1000", "spend": "50.00"})

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.get_insights.return_value = [insight]
            result = client.get_insights("camp_insight", object_type="campaign")

        assert len(result) == 1
        assert result[0]["impressions"] == "1000"
        MockCampaign.assert_called_once_with("camp_insight")

    def test_get_insights_for_adset_type(self, client):
        """object_type='adset'일 때 AdSet 객체의 get_insights를 호출한다."""
        insight = self._make_insight({"clicks": "200", "ctr": "2.00"})

        with patch("utils.meta_ads_client.AdSet") as MockAdSet:
            MockAdSet.return_value.get_insights.return_value = [insight]
            result = client.get_insights("adset_insight", object_type="adset")

        assert result[0]["clicks"] == "200"
        MockAdSet.assert_called_once_with("adset_insight")

    def test_get_insights_for_ad_type(self, client):
        """object_type='ad'일 때 Ad 객체의 get_insights를 호출한다."""
        insight = self._make_insight({"cpm": "3.50"})

        with patch("utils.meta_ads_client.Ad") as MockAd:
            MockAd.return_value.get_insights.return_value = [insight]
            result = client.get_insights("ad_insight", object_type="ad")

        assert result[0]["cpm"] == "3.50"
        MockAd.assert_called_once_with("ad_insight")

    def test_get_insights_invalid_object_type_raises_value_error(self, client):
        """지원하지 않는 object_type 전달 시 ValueError가 발생한다."""
        with pytest.raises(ValueError, match="지원하지 않는 object_type"):
            client.get_insights("some_id", object_type="invalid_type")

    def test_get_insights_uses_default_date_preset_when_no_range(self, client):
        """date_preset과 time_range 미지정 시 'last_7d' 프리셋이 기본으로 사용된다."""
        insight = self._make_insight({"spend": "10.00"})

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.get_insights.return_value = [insight]
            client.get_insights("camp_default")

        _, kwargs = MockCampaign.return_value.get_insights.call_args
        assert kwargs["params"]["date_preset"] == "last_7d"

    def test_get_insights_uses_given_date_preset(self, client):
        """date_preset을 지정하면 해당 값이 params에 포함된다."""
        insight = self._make_insight({})

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.get_insights.return_value = [insight]
            client.get_insights("camp_preset", date_preset="last_30d")

        _, kwargs = MockCampaign.return_value.get_insights.call_args
        assert kwargs["params"]["date_preset"] == "last_30d"

    def test_get_insights_uses_given_time_range(self, client):
        """time_range를 지정하면 date_preset 없이 time_range가 params에 포함된다."""
        insight = self._make_insight({})
        time_range = {"since": "2024-01-01", "until": "2024-01-31"}

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.get_insights.return_value = [insight]
            client.get_insights("camp_range", time_range=time_range)

        _, kwargs = MockCampaign.return_value.get_insights.call_args
        assert kwargs["params"].get("time_range") == time_range
        assert "date_preset" not in kwargs["params"]

    def test_get_insights_uses_custom_fields(self, client):
        """fields 인자를 지정하면 해당 필드 목록이 SDK에 전달된다."""
        insight = self._make_insight({"impressions": "500"})
        custom_fields = ["impressions", "spend"]

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.get_insights.return_value = [insight]
            client.get_insights("camp_fields", fields=custom_fields)

        _, kwargs = MockCampaign.return_value.get_insights.call_args
        assert kwargs["fields"] == custom_fields

    def test_get_insights_object_type_case_insensitive(self, client):
        """object_type은 대소문자를 구분하지 않는다 (Campaign, CAMPAIGN 모두 허용)."""
        insight = self._make_insight({"impressions": "100"})

        with patch("utils.meta_ads_client.Campaign") as MockCampaign:
            MockCampaign.return_value.get_insights.return_value = [insight]
            result = client.get_insights("camp_case", object_type="CAMPAIGN")

        assert len(result) == 1
        MockCampaign.assert_called_once_with("camp_case")


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