using MetaApi.Configuration; using MetaApi.Models; using Microsoft.Extensions.Options; namespace MetaApi.Services; /// /// Core service for Meta Marketing API operations. /// Follows the same dual-mode pattern as GoogleAdsService: /// - When EnableRealApi=false: returns emulated responses /// - When EnableRealApi=true: makes real Graph API calls /// /// Meta campaign hierarchy: Campaign → Ad Set → Ad /// Maps to platform model: Initiative → ChannelCampaign (meta) → provider entities /// public sealed class MetaMarketingService { private readonly MetaConfig _config; private readonly MetaGraphClient _graphClient; private readonly ILogger _logger; public MetaMarketingService( IOptions config, MetaGraphClient graphClient, ILogger logger) { _config = config.Value; _graphClient = graphClient; _logger = logger; } public async Task ExecuteAsync(ProviderRequest request, CancellationToken ct) { var requestId = request.RequestId ?? Guid.NewGuid().ToString("N"); var operation = (request.Operation ?? string.Empty).Trim(); _logger.LogInformation( "[MetaAds] Executing {Operation} | RequestId={RequestId} TenantId={TenantId} RealApi={RealApi}", operation, requestId, request.TenantId, _graphClient.IsRealApiEnabled); try { var context = new MetaApiContext { AdAccountId = NormalizeAdAccountId(request.TenantId ?? string.Empty), BusinessManagerId = request.LoginCustomerId ?? _config.BusinessManagerId }; var result = operation switch { "Ping" => Ping(requestId), "TestPing" => Ping(requestId), // Campaign operations "CreateCampaign" => await CreateCampaignAsync(request, context, requestId, ct), "GetCampaign" => await GetCampaignAsync(request, context, requestId, ct), "UpdateCampaign" => await UpdateCampaignAsync(request, context, requestId, ct), "ListCampaigns" => await ListCampaignsAsync(request, context, requestId, ct), // Insights (reporting) "GetCampaignInsights" => GetCampaignInsights(request, requestId), "GetAccountInsights" => GetAccountInsights(request, requestId), // Account management "CreateAdAccount" => await CreateAdAccountAsync(request, context, requestId, ct), "ListAdAccounts" => await ListAdAccountsAsync(context, requestId, ct), "" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"), _ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}") }; _logger.LogInformation( "[MetaAds] Completed {Operation} | RequestId={RequestId} Ok={Ok}", operation, requestId, result.Ok); return result; } catch (Exception ex) { _logger.LogError(ex, "[MetaAds] Error in {Operation} | RequestId={RequestId}", operation, requestId); return ProviderResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message); } } // ================================================================ // Ping // ================================================================ private ProviderResponse Ping(string requestId) => ProviderResponse.Success(requestId, new { message = "MetaApi provider is healthy", service = "MetaApi", realApiEnabled = _graphClient.IsRealApiEnabled, apiVersion = _config.ApiVersion, businessManagerId = _config.BusinessManagerId, timestamp = DateTimeOffset.UtcNow }); // ================================================================ // Campaign Operations // ================================================================ private async Task CreateCampaignAsync( ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); if (string.IsNullOrWhiteSpace(payload.Name)) return ProviderResponse.Fail(requestId, "VALIDATION", "Campaign name is required"); if (_graphClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdAccountId)) return await CreateCampaignRealAsync(payload, context, requestId, ct); // Emulated response var emulatedId = GenerateId().ToString(); return ProviderResponse.Success(requestId, new { campaignId = emulatedId, name = payload.Name, objective = MapObjectiveToApi(payload.Objective), status = MapStatusToApi(payload.Status), adAccountId = context.AdAccountId, effectiveStatus = "PAUSED", createdTime = DateTimeOffset.UtcNow.ToString("o"), emulated = true }); } private async Task CreateCampaignRealAsync( CreateCampaignPayload payload, MetaApiContext context, string requestId, CancellationToken ct) { // POST /{ad-account-id}/campaigns var formData = new Dictionary { ["name"] = payload.Name, ["objective"] = MapObjectiveToApi(payload.Objective), ["status"] = MapStatusToApi(payload.Status), ["special_ad_categories"] = payload.SpecialAdCategories.Count > 0 ? $"[{string.Join(",", payload.SpecialAdCategories.Select(c => $"\"{c}\""))}]" : "[]" }; if (payload.SpendCapCents.HasValue) formData["spend_cap"] = payload.SpendCapCents.Value.ToString(); var result = await _graphClient.PostAsync($"{context.AdAccountId}/campaigns", formData, ct); if (!result.IsSuccess) return ProviderResponse.Fail(requestId, "META_API_ERROR", result.ErrorMessage ?? "Failed to create campaign", new { metaErrorCode = result.ErrorCode, metaErrorSubcode = result.ErrorSubcode }); var campaignId = result.Data?.TryGetProperty("id", out var idProp) == true ? idProp.GetString() : null; return ProviderResponse.Success(requestId, new { campaignId, name = payload.Name, objective = MapObjectiveToApi(payload.Objective), status = MapStatusToApi(payload.Status), adAccountId = context.AdAccountId, emulated = false }); } private async Task GetCampaignAsync( ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); if (string.IsNullOrWhiteSpace(payload.CampaignId)) return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"); if (_graphClient.IsRealApiEnabled) return await GetCampaignRealAsync(payload.CampaignId, requestId, ct); // Emulated return ProviderResponse.Success(requestId, new { campaignId = payload.CampaignId, name = $"Emulated Campaign {payload.CampaignId}", objective = "OUTCOME_TRAFFIC", status = "PAUSED", effectiveStatus = "PAUSED", dailyBudget = "5000", lifetimeBudget = "0", createdTime = DateTimeOffset.UtcNow.AddDays(-7).ToString("o"), updatedTime = DateTimeOffset.UtcNow.ToString("o"), emulated = true }); } private async Task GetCampaignRealAsync( string campaignId, string requestId, CancellationToken ct) { var fields = new Dictionary { ["fields"] = "id,name,objective,status,effective_status,daily_budget,lifetime_budget,created_time,updated_time,spend_cap,special_ad_categories" }; var result = await _graphClient.GetAsync(campaignId, fields, ct); if (!result.IsSuccess) return ProviderResponse.Fail(requestId, "META_API_ERROR", result.ErrorMessage ?? "Failed to get campaign"); return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false }); } private async Task UpdateCampaignAsync( ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); if (string.IsNullOrWhiteSpace(payload.CampaignId)) return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"); if (_graphClient.IsRealApiEnabled) return await UpdateCampaignRealAsync(payload, requestId, ct); // Emulated return ProviderResponse.Success(requestId, new { campaignId = payload.CampaignId, updated = true, name = payload.Name, status = payload.Status?.ToString()?.ToUpper() ?? "PAUSED", emulated = true }); } private async Task UpdateCampaignRealAsync( UpdateCampaignPayload payload, string requestId, CancellationToken ct) { var formData = new Dictionary(); if (!string.IsNullOrWhiteSpace(payload.Name)) formData["name"] = payload.Name; if (payload.Status.HasValue) formData["status"] = MapStatusToApi(payload.Status.Value); if (payload.SpendCapCents.HasValue) formData["spend_cap"] = payload.SpendCapCents.Value.ToString(); if (formData.Count == 0) return ProviderResponse.Fail(requestId, "VALIDATION", "No fields to update"); var result = await _graphClient.PostAsync(payload.CampaignId, formData, ct); if (!result.IsSuccess) return ProviderResponse.Fail(requestId, "META_API_ERROR", result.ErrorMessage ?? "Failed to update campaign"); return ProviderResponse.Success(requestId, new { campaignId = payload.CampaignId, updated = true, emulated = false }); } private async Task ListCampaignsAsync( ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); if (_graphClient.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.AdAccountId)) return await ListCampaignsRealAsync(payload, context, requestId, ct); // Emulated var campaigns = Enumerable.Range(1, 3).Select(i => new { id = GenerateId().ToString(), name = $"Emulated Campaign {i}", objective = "OUTCOME_TRAFFIC", status = i == 1 ? "ACTIVE" : "PAUSED", effectiveStatus = i == 1 ? "ACTIVE" : "PAUSED", dailyBudget = (5000 * i).ToString(), createdTime = DateTimeOffset.UtcNow.AddDays(-i * 7).ToString("o") }); return ProviderResponse.Success(requestId, new { campaigns, adAccountId = context.AdAccountId, emulated = true }); } private async Task ListCampaignsRealAsync( ListCampaignsPayload payload, MetaApiContext context, string requestId, CancellationToken ct) { var queryParams = new Dictionary { ["fields"] = "id,name,objective,status,effective_status,daily_budget,lifetime_budget,created_time,updated_time", ["limit"] = payload.Limit.ToString() }; if (payload.StatusFilter.HasValue) { var apiStatus = MapStatusToApi(payload.StatusFilter.Value); queryParams["filtering"] = $"[{{\"field\":\"effective_status\",\"operator\":\"IN\",\"value\":[\"{apiStatus}\"]}}]"; } if (!string.IsNullOrWhiteSpace(payload.After)) queryParams["after"] = payload.After; var result = await _graphClient.GetAsync($"{context.AdAccountId}/campaigns", queryParams, ct); if (!result.IsSuccess) return ProviderResponse.Fail(requestId, "META_API_ERROR", result.ErrorMessage ?? "Failed to list campaigns"); return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false }); } // ================================================================ // Insights (Reporting) - emulated only for now // ================================================================ private ProviderResponse GetCampaignInsights(ProviderRequest request, string requestId) { var payload = request.GetPayload(); var rng = new Random(); var days = Enumerable.Range(0, 7).Select(i => { var date = DateTime.UtcNow.Date.AddDays(-i); var impressions = rng.Next(1000, 50000); var clicks = rng.Next(50, impressions / 10); var spend = Math.Round(clicks * (rng.NextDouble() * 2 + 0.5), 2); return new { dateStart = date.ToString("yyyy-MM-dd"), dateStop = date.ToString("yyyy-MM-dd"), impressions = impressions.ToString(), clicks = clicks.ToString(), spend = spend.ToString("F2"), ctr = (clicks * 100.0 / impressions).ToString("F2"), cpc = (spend / clicks).ToString("F2"), cpm = (spend / impressions * 1000).ToString("F2") }; }).Reverse(); return ProviderResponse.Success(requestId, new { campaignId = payload.CampaignId, insights = days, emulated = true }); } private ProviderResponse GetAccountInsights(ProviderRequest request, string requestId) { var payload = request.GetPayload(); return ProviderResponse.Success(requestId, new { totalSpend = "12450.00", totalImpressions = "845230", totalClicks = "23456", totalConversions = "567", ctr = "2.78", cpc = "0.53", dateRange = new { start = DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd"), end = DateTime.UtcNow.ToString("yyyy-MM-dd") }, emulated = true }); } // ================================================================ // Account Management // ================================================================ private async Task CreateAdAccountAsync( ProviderRequest request, MetaApiContext context, string requestId, CancellationToken ct) { var payload = request.GetPayload(); if (string.IsNullOrWhiteSpace(payload.Name)) return ProviderResponse.Fail(requestId, "VALIDATION", "Account name is required"); if (string.IsNullOrWhiteSpace(context.BusinessManagerId)) return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessManagerId is required"); if (_graphClient.IsRealApiEnabled) return await CreateAdAccountRealAsync(payload, context, requestId, ct); // Emulated return ProviderResponse.Success(requestId, new { adAccountId = $"act_{GenerateId()}", name = payload.Name, currency = payload.Currency, businessManagerId = context.BusinessManagerId, status = 1, // Meta: 1=Active, 2=Disabled, 3=Unsettled, 7=Pending Review, etc. emulated = true }); } private async Task CreateAdAccountRealAsync( CreateAdAccountPayload payload, MetaApiContext context, string requestId, CancellationToken ct) { // POST /{business-id}/adaccount var formData = new Dictionary { ["name"] = payload.Name, ["currency"] = payload.Currency, ["timezone_id"] = payload.TimezoneId.ToString(), ["end_advertiser"] = payload.EndAdvertiser ?? _config.BusinessManagerId, ["media_agency"] = payload.MediaAgency ?? _config.BusinessManagerId, ["partner"] = payload.Partner ?? _config.BusinessManagerId }; var result = await _graphClient.PostAsync($"{context.BusinessManagerId}/adaccount", formData, ct); if (!result.IsSuccess) return ProviderResponse.Fail(requestId, "META_API_ERROR", result.ErrorMessage ?? "Failed to create ad account", new { metaErrorCode = result.ErrorCode, metaErrorSubcode = result.ErrorSubcode }); var accountId = result.Data?.TryGetProperty("id", out var idProp) == true ? idProp.GetString() : null; return ProviderResponse.Success(requestId, new { adAccountId = accountId, name = payload.Name, currency = payload.Currency, businessManagerId = context.BusinessManagerId, emulated = false }); } private async Task ListAdAccountsAsync( MetaApiContext context, string requestId, CancellationToken ct) { if (string.IsNullOrWhiteSpace(context.BusinessManagerId)) return ProviderResponse.Fail(requestId, "VALIDATION", "BusinessManagerId is required"); if (_graphClient.IsRealApiEnabled) { var queryParams = new Dictionary { ["fields"] = "id,name,account_status,currency,timezone_name,amount_spent,balance", ["limit"] = "100" }; var result = await _graphClient.GetAsync($"{context.BusinessManagerId}/owned_ad_accounts", queryParams, ct); if (!result.IsSuccess) return ProviderResponse.Fail(requestId, "META_API_ERROR", result.ErrorMessage ?? "Failed to list ad accounts"); return ProviderResponse.Success(requestId, new { raw = result.Data, emulated = false }); } // Emulated var accounts = Enumerable.Range(1, 3).Select(i => new { id = $"act_{GenerateId()}", name = $"Client Account {i}", accountStatus = 1, currency = "USD", timezoneName = "America/Los_Angeles", amountSpent = (i * 4500).ToString(), balance = "0" }); return ProviderResponse.Success(requestId, new { accounts, businessManagerId = context.BusinessManagerId, emulated = true }); } // ================================================================ // Helpers // ================================================================ /// /// Ensure ad account ID has "act_" prefix (Meta requirement). /// private static string NormalizeAdAccountId(string id) { if (string.IsNullOrWhiteSpace(id)) return string.Empty; return id.StartsWith("act_", StringComparison.OrdinalIgnoreCase) ? id : $"act_{id}"; } /// /// Map platform objective enum to Meta API string. /// Uses ODAX (Outcome-Driven Ad Experiences) objective names as of v18.0+. /// private static string MapObjectiveToApi(MetaObjective objective) => objective switch { MetaObjective.Awareness => "OUTCOME_AWARENESS", MetaObjective.Traffic => "OUTCOME_TRAFFIC", MetaObjective.Engagement => "OUTCOME_ENGAGEMENT", MetaObjective.Leads => "OUTCOME_LEADS", MetaObjective.AppPromotion => "OUTCOME_APP_PROMOTION", MetaObjective.Conversions => "OUTCOME_SALES", _ => "OUTCOME_TRAFFIC" }; /// /// Map platform status enum to Meta API string. /// private static string MapStatusToApi(MetaCampaignStatus status) => status switch { MetaCampaignStatus.Active => "ACTIVE", MetaCampaignStatus.Paused => "PAUSED", MetaCampaignStatus.Deleted => "DELETED", MetaCampaignStatus.Archived => "ARCHIVED", _ => "PAUSED" }; private static long GenerateId() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000 + Random.Shared.Next(999); }